diff --git a/arkindex/documents/api/elements.py b/arkindex/documents/api/elements.py
index 4e95292bff86b183a92ac84a0cbecf66930d57ac..b8d30c77ce88305ca3f24b5b13b57fe106643f7c 100644
--- a/arkindex/documents/api/elements.py
+++ b/arkindex/documents/api/elements.py
@@ -10,7 +10,7 @@ from django.db.models.functions import Cast
 from django.shortcuts import get_object_or_404
 from django.utils.functional import cached_property
 from psycopg2.extras import execute_values
-from rest_framework import serializers, status
+from rest_framework import permissions, serializers, status
 from rest_framework.exceptions import NotFound, ValidationError
 from rest_framework.generics import (
     CreateAPIView,
@@ -21,6 +21,7 @@ from rest_framework.generics import (
     RetrieveUpdateDestroyAPIView,
     UpdateAPIView,
 )
+from rest_framework.mixins import DestroyModelMixin
 from rest_framework.response import Response
 
 from arkindex.dataimport.models import WorkerVersion
@@ -56,7 +57,7 @@ from arkindex.project.openapi import AutoSchema
 from arkindex.project.pagination import PageNumberPagination
 from arkindex.project.permissions import IsAuthenticated, IsVerified, IsVerifiedOrReadOnly
 from arkindex.project.tools import BulkMap
-from arkindex.project.triggers import corpus_delete
+from arkindex.project.triggers import corpus_delete, element_trash
 from arkindex_common.enums import TranscriptionType
 
 classifications_queryset = Classification.objects.select_related('ml_class', 'source').order_by('-confidence')
@@ -124,6 +125,7 @@ class ElementsListMixin(object):
     """
     serializer_class = ElementListSerializer
     queryset = Element.objects.all()
+    permission_classes = (IsVerifiedOrReadOnly, )
     openapi_overrides = {
         'security': [],
         'tags': ['elements'],
@@ -135,7 +137,7 @@ class ElementsListMixin(object):
                 'required': False,
                 'schema': {
                     'type': 'string',
-                }
+                },
             },
             {
                 'name': 'name',
@@ -153,7 +155,8 @@ class ElementsListMixin(object):
                 'schema': {'anyOf': [
                     {'type': 'string', 'format': 'uuid'},
                     {'type': 'boolean'},
-                ]}
+                ]},
+                'x-methods': ['get'],
             },
             {
                 'name': 'folder',
@@ -185,7 +188,8 @@ class ElementsListMixin(object):
                 'schema': {
                     'type': 'boolean',
                     'default': False,
-                }
+                },
+                'x-methods': ['get'],
             },
             {
                 'name': 'with_has_children',
@@ -198,7 +202,8 @@ class ElementsListMixin(object):
                 'schema': {
                     'type': 'boolean',
                     'default': False
-                }
+                },
+                'x-methods': ['get'],
             },
             {
                 'name': 'If-Modified-Since',
@@ -214,20 +219,66 @@ class ElementsListMixin(object):
                 'schema': {
                     # Cannot use format: date-time here as HTTP headers do not use ISO 8601
                     'type': 'string'
-                }
+                },
+                'x-methods': ['get'],
+            },
+            {
+                'name': 'delete_children',
+                'required': False,
+                'in': 'query',
+                'description': 'Delete all child elements of those elements recursively.',
+                'schema': {
+                    'type': 'boolean',
+                    'default': True
+                },
+                'x-methods': ['delete'],
             }
         ]
     }
 
+    def initial(self, *args, **kwargs):
+        """
+        Adds a `self.clean_params` attribute that holds any query params or headers,
+        filtered by their allowed HTTP method.
+        Raise HTTP 400 early if any filter is not allowed in this method.
+        Does not perform validation on the filter values.
+        """
+        super().initial(*args, **kwargs)
+
+        self.clean_params = {}
+        errors = {}
+        for param in self.openapi_overrides['parameters']:
+            if param['in'] == 'header':
+                origin_dict = self.request.headers
+            elif param['in'] == 'query':
+                origin_dict = self.request.query_params
+            else:
+                raise NotImplementedError
+
+            if param['name'] not in origin_dict:
+                continue
+
+            if param.get('x-methods') and self.request.method.lower() not in param['x-methods']:
+                errors[param['name']] = [f'This parameter is not allowed in {self.request.method} requests.']
+            else:
+                self.clean_params[param['name']] = origin_dict[param['name']]
+
+        if errors:
+            raise ValidationError(errors)
+
     @cached_property
     def selected_corpus(self):
         corpus_id = self.kwargs.get('corpus')
         if corpus_id is None:
             return
 
-        # When a corpus is specified, check independently that it's readable
-        # by the current user. This prevents comparing the corpus of every element
-        return self.get_corpus(corpus_id)
+        # When a corpus is specified, check independently that it's readable/writable
+        # by the current user. This prevents comparing the corpus of every element.
+        # Require write rights for 'unsafe' methods (POST/PUT/PATCH/DELETE)
+        return self.get_corpus(
+            corpus_id,
+            right=Right.Read if self.request.method in permissions.SAFE_METHODS else Right.Write,
+        )
 
     def get_filters(self):
         filters = {}
@@ -237,16 +288,16 @@ class ElementsListMixin(object):
             filters['corpus__in'] = Corpus.objects.readable(self.request.user)
 
         if 'name' in self.request.query_params:
-            filters['name__icontains'] = self.request.query_params['name']
+            filters['name__icontains'] = self.clean_params['name']
 
         if 'type' in self.request.query_params:
-            filters['type__slug'] = self.request.query_params['type']
+            filters['type__slug'] = self.clean_params['type']
 
-        only_folder = self.request.query_params.get('folder')
+        only_folder = self.clean_params.get('folder')
         if only_folder is not None:
             filters['type__folder'] = only_folder.lower() not in ('false', '0')
 
-        if 'worker_version' in self.request.query_params:
+        if 'worker_version' in self.clean_params:
             try:
                 worker_version_id = uuid.UUID(self.request.query_params['worker_version'])
             except (TypeError, ValueError):
@@ -267,7 +318,7 @@ class ElementsListMixin(object):
         - elements with any best classes
         - elements with a specific best class
         """
-        class_filter = self.request.query_params.get('best_class')
+        class_filter = self.clean_params.get('best_class')
         if class_filter is None:
             return
 
@@ -292,7 +343,7 @@ class ElementsListMixin(object):
     def get_prefetch(self):
         prefetch = {'corpus', 'zone__image__server', 'type'}
 
-        with_best_classes = self.request.query_params.get('with_best_classes')
+        with_best_classes = self.clean_params.get('with_best_classes')
         if with_best_classes and with_best_classes.lower() not in ('false', '0'):
             prefetch.add(best_classifications_prefetch)
 
@@ -313,7 +364,7 @@ class ElementsListMixin(object):
             # Use queryset.distinct() whenever best_class is defined
             queryset = queryset.filter(class_filters).distinct()
 
-        with_has_children = self.request.query_params.get('with_has_children')
+        with_has_children = self.clean_params.get('with_has_children')
         if with_has_children and with_has_children.lower() not in ('false', '0'):
             queryset = BulkMap(_fetch_has_children, queryset)
 
@@ -328,7 +379,7 @@ class ElementsListMixin(object):
             queryset = queryset.iterable
         assert isinstance(queryset, QuerySet), 'A Django QuerySet is required to check for modified elements'
 
-        modified_since_string = self.request.headers.get('If-Modified-Since')
+        modified_since_string = self.clean_params.get('If-Modified-Since')
         if not modified_since_string:
             return False
 
@@ -358,6 +409,16 @@ class ElementsListMixin(object):
         serializer = self.get_serializer(queryset, many=True)
         return Response(serializer.data)
 
+    def delete(self, request, *args, **kwargs):
+        queryset = self.filter_queryset(self.get_queryset())
+        delete_children = self.clean_params.get('delete_children', '').lower() not in ('false', '0')
+
+        if not queryset.exists():
+            raise NotFound
+
+        element_trash(queryset, delete_children=delete_children)
+        return Response(status=status.HTTP_204_NO_CONTENT)
+
 
 class DeprecatedElementsList(DeprecatedMixin, RetrieveAPIView):
     """
@@ -376,7 +437,7 @@ class DeprecatedElementsList(DeprecatedMixin, RetrieveAPIView):
     )
 
 
-class CorpusElements(ElementsListMixin, CorpusACLMixin, ListAPIView):
+class CorpusElements(ElementsListMixin, CorpusACLMixin, DestroyModelMixin, ListAPIView):
     """
     List elements in a corpus and filter by type, name, ML class
     """
@@ -396,7 +457,7 @@ class CorpusElements(ElementsListMixin, CorpusACLMixin, ListAPIView):
 
     @property
     def is_top_level(self):
-        return self.request.query_params.get('top_level') not in (None, 'false', '0')
+        return self.clean_params.get('top_level') not in (None, 'false', '0')
 
     def get_queryset(self):
         # Should not be possible due to the URL
@@ -421,7 +482,7 @@ class CorpusElements(ElementsListMixin, CorpusACLMixin, ListAPIView):
         return super().get_order_by()
 
 
-class ElementParents(ElementsListMixin, ListAPIView):
+class ElementParents(ElementsListMixin, DestroyModelMixin, ListAPIView):
     """
     List all parents of an element
     """
@@ -445,14 +506,22 @@ class ElementParents(ElementsListMixin, ListAPIView):
 
     @property
     def is_recursive(self):
-        recursive_param = self.request.query_params.get('recursive')
+        recursive_param = self.clean_params.get('recursive')
         return recursive_param is not None and recursive_param.lower() not in ('false', '0')
 
     def get_queryset(self):
+        if self.request.method in permissions.SAFE_METHODS:
+            corpora = Corpus.objects.readable(self.request.user)
+        else:
+            corpora = Corpus.objects.writable(self.request.user)
+
+        if not Element.objects.filter(id=self.kwargs['pk'], corpus__in=corpora).exists():
+            raise NotFound
+
         return Element.objects.get_ascending(self.kwargs['pk'], recursive=self.is_recursive)
 
 
-class ElementChildren(ElementsListMixin, ListAPIView):
+class ElementChildren(ElementsListMixin, DestroyModelMixin, ListAPIView):
     """
     List all children of an element
     """
@@ -476,7 +545,7 @@ class ElementChildren(ElementsListMixin, ListAPIView):
 
     def get_filters(self):
         filters = super().get_filters()
-        recursive_param = self.request.query_params.get('recursive')
+        recursive_param = self.clean_params.get('recursive')
 
         if recursive_param is None or recursive_param.lower() in ('false', '0'):
             # Only list direct children
@@ -492,6 +561,14 @@ class ElementChildren(ElementsListMixin, ListAPIView):
         return ('paths__ordering', )
 
     def get_queryset(self):
+        if self.request.method in permissions.SAFE_METHODS:
+            corpora = Corpus.objects.readable(self.request.user)
+        else:
+            corpora = Corpus.objects.writable(self.request.user)
+
+        if not Element.objects.filter(id=self.kwargs['pk'], corpus__in=corpora).exists():
+            raise NotFound
+
         return Element.objects.get_descending(self.kwargs['pk'])
 
 
diff --git a/arkindex/documents/managers.py b/arkindex/documents/managers.py
index 7aa35ba64caedb5bcc4cb4e4559809f153e2888c..be83286798c03a2d2cd11bead087cf1d7d08d19c 100644
--- a/arkindex/documents/managers.py
+++ b/arkindex/documents/managers.py
@@ -1,13 +1,24 @@
 import uuid
 from itertools import chain, groupby
 
-from django.db import connections, models
+import django
+from django.db import DJANGO_VERSION_PICKLE_KEY, connections, models
 
 from arkindex.project.fields import Unnest
 
 
 class ElementQuerySet(models.QuerySet):
 
+    def __getstate__(self):
+        """
+        Django's QuerySets can be pickled, but when they are, they first call self._fetch_all()
+        to fetch every row into the result cache and store it along with the pickled data,
+        because one could expect to 'save' a queryset that way.
+        We only pickle querysets for RQ tasks that will call .trash() later;
+        this will never use the result cache as it does not load rows into memory, so we ignore that step.
+        """
+        return {**self.__dict__, DJANGO_VERSION_PICKLE_KEY: django.__version__}
+
     def trash(self, delete_children=True):
         """Performant deletion of any element queryset"""
         from arkindex.documents.models import Element, MetaData, Transcription, TranscriptionEntity, Classification, Selection
diff --git a/arkindex/documents/tasks.py b/arkindex/documents/tasks.py
index b178a81698f7f2f646478d8811e2fdafb3980297..8d0c98502a5061fbec1285f8c2c62e4145bec089 100644
--- a/arkindex/documents/tasks.py
+++ b/arkindex/documents/tasks.py
@@ -8,6 +8,7 @@ from django_rq import job
 
 from arkindex.dataimport.models import DataImport, DataImportElement, WorkerRun
 from arkindex.documents.indexer import Indexer
+from arkindex.documents.managers import ElementQuerySet
 from arkindex.documents.models import (
     Classification,
     Corpus,
@@ -193,3 +194,8 @@ def corpus_delete(corpus_id: str) -> None:
         logger.info(f'Deleted {deleted_count} {queryset.model.__name__}')
 
     logger.info(f'Deleted corpus {corpus_id}')
+
+
+@job('high')
+def element_trash(queryset: ElementQuerySet, delete_children: bool) -> None:
+    queryset.trash(delete_children=delete_children)
diff --git a/arkindex/documents/tests/test_children_elements.py b/arkindex/documents/tests/test_children_elements.py
index e78f7a002885642f939cebd711c2b15ca3b6045e..df19a1b9f6dd5af637bb95bfb0fc1f721b484e50 100644
--- a/arkindex/documents/tests/test_children_elements.py
+++ b/arkindex/documents/tests/test_children_elements.py
@@ -104,7 +104,7 @@ class TestChildrenElements(FixtureAPITestCase):
     def test_element_children_worker_version(self):
         self.corpus.elements.filter(name__contains='page 1r').update(worker_version=self.worker_version)
 
-        with self.assertNumQueries(8):
+        with self.assertNumQueries(9):
             response = self.client.get(
                 reverse('api:elements-children', kwargs={'pk': str(self.vol.id)}),
                 data={'worker_version': str(self.worker_version.id)}
@@ -118,7 +118,7 @@ class TestChildrenElements(FixtureAPITestCase):
         )
 
     def test_element_children_worker_version_validation(self):
-        with self.assertNumQueries(0):
+        with self.assertNumQueries(1):
             response = self.client.get(
                 reverse('api:elements-children', kwargs={'pk': str(self.vol.id)}),
                 data={'worker_version': 'blah'},
@@ -126,7 +126,7 @@ class TestChildrenElements(FixtureAPITestCase):
             self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
         self.assertEqual(response.json(), {'worker_version': ['Invalid UUID']})
 
-        with self.assertNumQueries(1):
+        with self.assertNumQueries(2):
             response = self.client.get(
                 reverse('api:elements-children', kwargs={'pk': str(self.vol.id)}),
                 data={'worker_version': uuid.uuid4()},
@@ -163,7 +163,7 @@ class TestChildrenElements(FixtureAPITestCase):
         )
         element.add_parent(self.vol)
 
-        with self.assertNumQueries(9):
+        with self.assertNumQueries(10):
             response = self.client.get(
                 reverse('api:elements-children', kwargs={'pk': str(self.vol.id)}),
                 data={'with_has_children': True},
@@ -185,7 +185,7 @@ class TestChildrenElements(FixtureAPITestCase):
         )
 
     def test_children_modified_since_bad_format(self):
-        with self.assertNumQueries(0):
+        with self.assertNumQueries(1):
             response = self.client.get(
                 reverse('api:elements-children', kwargs={'pk': str(self.vol.id)}),
                 HTTP_IF_MODIFIED_SINCE='AAAAAAAAAAAAAAAAA',
@@ -194,7 +194,7 @@ class TestChildrenElements(FixtureAPITestCase):
         self.assertDictEqual(response.json(), {'If-Modified-Since': ['Bad date format']})
 
     def test_children_modified_since_not_modified(self):
-        with self.assertNumQueries(1):
+        with self.assertNumQueries(2):
             response = self.client.get(
                 reverse('api:elements-children', kwargs={'pk': str(self.vol.id)}),
                 HTTP_IF_MODIFIED_SINCE='Thu, 02 Apr 2099 13:37:42 GMT',
@@ -203,7 +203,7 @@ class TestChildrenElements(FixtureAPITestCase):
 
     def test_children_modified_since(self):
         self.corpus.elements.filter(name='Volume 1, page 1r').update(updated='2099-04-02T13:37:43Z')
-        with self.assertNumQueries(8):
+        with self.assertNumQueries(9):
             response = self.client.get(
                 reverse('api:elements-children', kwargs={'pk': str(self.vol.id)}),
                 HTTP_IF_MODIFIED_SINCE='Thu, 02 Apr 2099 13:37:42 GMT',
diff --git a/arkindex/documents/tests/test_classes.py b/arkindex/documents/tests/test_classes.py
index 6136b229d95de1e481cfca84914113ea2492a671..4984fef2ee2260147d3820905f99a995f7807382 100644
--- a/arkindex/documents/tests/test_classes.py
+++ b/arkindex/documents/tests/test_classes.py
@@ -265,7 +265,7 @@ class TestClasses(FixtureAPITestCase):
 
     def test_element_parents_best_classes(self):
         self.populate_classified_elements()
-        with self.assertNumQueries(6):
+        with self.assertNumQueries(7):
             response = self.client.get(
                 reverse('api:elements-parents', kwargs={'pk': str(self.common_children.id)}),
                 data={'type': self.classified.slug, 'with_best_classes': 1}
@@ -281,7 +281,7 @@ class TestClasses(FixtureAPITestCase):
 
     def test_element_children_best_classes(self):
         self.populate_classified_elements()
-        with self.assertNumQueries(6):
+        with self.assertNumQueries(7):
             response = self.client.get(
                 reverse('api:elements-children', kwargs={'pk': str(self.parent.id)}),
                 data={'type': self.classified.slug, 'with_best_classes': 'yes'}
@@ -302,7 +302,7 @@ class TestClasses(FixtureAPITestCase):
         self.populate_classified_elements()
         child = Element.objects.filter(type=self.classified.id).first()
         child.classifications.all().update(state=ClassificationState.Rejected)
-        with self.assertNumQueries(6):
+        with self.assertNumQueries(7):
             response = self.client.get(
                 reverse('api:elements-children', kwargs={'pk': str(self.parent.id)}),
                 data={'type': self.classified.slug, 'with_best_classes': 'yes'}
@@ -405,7 +405,7 @@ class TestClasses(FixtureAPITestCase):
         self.populate_classified_elements()
         parent = Element.objects.get_ascending(self.common_children.id).last()
         parent.classifications.all().filter(confidence=.7).update(state=ClassificationState.Validated)
-        with self.assertNumQueries(5):
+        with self.assertNumQueries(6):
             response = self.client.get(
                 reverse('api:elements-parents', kwargs={'pk': str(self.common_children.id)}),
                 data={'type': self.classified.slug, 'best_class': str(self.text.id)}
@@ -419,7 +419,7 @@ class TestClasses(FixtureAPITestCase):
         self.populate_classified_elements()
         child = Element.objects.filter(type=self.classified.id).first()
         child.classifications.all().filter(confidence=.7).update(state=ClassificationState.Validated)
-        with self.assertNumQueries(5):
+        with self.assertNumQueries(6):
             response = self.client.get(
                 reverse('api:elements-children', kwargs={'pk': str(self.parent.id)}),
                 data={'type': self.classified.slug, 'best_class': str(self.text.id)}
@@ -449,7 +449,7 @@ class TestClasses(FixtureAPITestCase):
         self.populate_classified_elements()
         self.assertEqual(Classification.objects.filter(high_confidence=True).count(), 24)
         self.assertEqual(Classification.objects.filter(high_confidence=True).distinct('element_id').count(), 12)
-        with self.assertNumQueries(5):
+        with self.assertNumQueries(6):
             response = self.client.get(
                 reverse('api:elements-parents', kwargs={'pk': str(self.common_children.id)}),
                 data={'type': self.classified.slug, 'best_class': str(self.cover.id)}
@@ -465,7 +465,7 @@ class TestClasses(FixtureAPITestCase):
         self.populate_classified_elements()
         self.assertEqual(Classification.objects.filter(high_confidence=True).count(), 24)
         self.assertEqual(Classification.objects.filter(high_confidence=True).distinct('element_id').count(), 12)
-        with self.assertNumQueries(5):
+        with self.assertNumQueries(6):
             response = self.client.get(
                 reverse('api:elements-children', kwargs={'pk': str(self.parent.id)}),
                 data={'type': self.classified.slug, 'best_class': str(self.cover.id)}
diff --git a/arkindex/documents/tests/test_destroy_elements.py b/arkindex/documents/tests/test_destroy_elements.py
index 18e0b5b207e20d37a01fd7ba60f6d7162ee70a36..1d1f69a3936333332f331a0ebefa43cd88824a73 100644
--- a/arkindex/documents/tests/test_destroy_elements.py
+++ b/arkindex/documents/tests/test_destroy_elements.py
@@ -1,3 +1,5 @@
+from unittest.mock import patch
+
 from django.urls import reverse
 from rest_framework import status
 
@@ -13,6 +15,7 @@ class TestDestroyElements(FixtureAPITestCase):
         super().setUpTestData()
         cls.volume_type = cls.corpus.types.get(slug='volume')
         cls.vol = cls.corpus.elements.get(name='Volume 1')
+        cls.surface = cls.corpus.elements.get(name='Surface A')
         cls.private_corpus = Corpus.objects.create(name='private', public=False)
 
     def test_element_destroy_verified_user(self):
@@ -160,3 +163,258 @@ class TestDestroyElements(FixtureAPITestCase):
             Element.objects.filter(id=elements['A'].id).trash()
 
         self.assertFalse(Element.objects.filter(id__in=[e.id for e in elements.values()]).exists())
+
+    @patch('arkindex.project.triggers.tasks.element_trash.delay')
+    def test_destroy_corpus_elements_requires_login(self, delay_mock):
+        with self.assertNumQueries(0):
+            response = self.client.delete(reverse('api:corpus-elements', kwargs={'corpus': self.corpus.id}))
+            self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+        self.assertFalse(delay_mock.called)
+
+    @patch('arkindex.project.triggers.tasks.element_trash.delay')
+    def test_destroy_corpus_elements_requires_writable(self, delay_mock):
+        self.user.corpus_right.filter(corpus=self.corpus).update(can_write=False, can_admin=False)
+        self.client.force_login(self.user)
+
+        with self.assertNumQueries(4):
+            response = self.client.delete(reverse('api:corpus-elements', kwargs={'corpus': self.corpus.id}))
+            self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+        self.assertFalse(delay_mock.called)
+
+    @patch('arkindex.project.triggers.tasks.element_trash.delay')
+    def test_destroy_corpus_elements_empty(self, delay_mock):
+        self.client.force_login(self.user)
+        self.assertFalse(self.corpus.elements.filter(name='blablablabla').exists())
+
+        with self.assertNumQueries(5):
+            response = self.client.delete(
+                reverse('api:corpus-elements', kwargs={'corpus': self.corpus.id}),
+                QUERY_STRING='name=blablablabla',
+            )
+            self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+        self.assertFalse(delay_mock.called)
+
+    @patch('arkindex.project.triggers.tasks.element_trash.delay')
+    def test_destroy_corpus_elements(self, delay_mock):
+        self.client.force_login(self.user)
+        with self.assertNumQueries(5):
+            response = self.client.delete(reverse('api:corpus-elements', kwargs={'corpus': self.corpus.id}))
+            self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
+
+        self.assertEqual(delay_mock.call_count, 1)
+        args, kwargs = delay_mock.call_args
+        self.assertEqual(len(args), 0)
+        self.assertCountEqual(list(kwargs.pop('queryset')), list(self.corpus.elements.all()))
+        self.assertDictEqual(kwargs, {'delete_children': True})
+
+    @patch('arkindex.project.triggers.tasks.element_trash.delay')
+    def test_destroy_corpus_elements_delete_children(self, delay_mock):
+        self.client.force_login(self.user)
+        with self.assertNumQueries(5):
+            response = self.client.delete(
+                reverse('api:corpus-elements', kwargs={'corpus': self.corpus.id}),
+                QUERY_STRING='delete_children=false'
+            )
+            self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
+
+        self.assertEqual(delay_mock.call_count, 1)
+        args, kwargs = delay_mock.call_args
+        self.assertEqual(len(args), 0)
+        self.assertCountEqual(list(kwargs.pop('queryset')), list(self.corpus.elements.all()))
+        self.assertDictEqual(kwargs, {'delete_children': False})
+
+    @patch('arkindex.project.triggers.tasks.element_trash.delay')
+    def test_destroy_corpus_elements_rejected_filters(self, delay_mock):
+        self.client.force_login(self.user)
+        with self.assertNumQueries(2):
+            response = self.client.delete(
+                reverse('api:corpus-elements', kwargs={'corpus': self.corpus.id}),
+                QUERY_STRING='with_best_classes=True&with_has_children=True&best_class=True',
+                HTTP_IF_MODIFIED_SINCE='Thu, 02 Apr 2099 13:37:42 GMT',
+            )
+            self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
+        self.assertDictEqual(response.json(), {
+            'If-Modified-Since': ['This parameter is not allowed in DELETE requests.'],
+            'best_class': ['This parameter is not allowed in DELETE requests.'],
+            'with_best_classes': ['This parameter is not allowed in DELETE requests.'],
+            'with_has_children': ['This parameter is not allowed in DELETE requests.'],
+        })
+
+        self.assertFalse(delay_mock.called)
+
+    @patch('arkindex.project.triggers.tasks.element_trash.delay')
+    def test_destroy_element_children_requires_login(self, delay_mock):
+        with self.assertNumQueries(0):
+            response = self.client.delete(reverse('api:elements-children', kwargs={'pk': self.vol.id}))
+            self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+        self.assertFalse(delay_mock.called)
+
+    @patch('arkindex.project.triggers.tasks.element_trash.delay')
+    def test_destroy_element_children_requires_writable(self, delay_mock):
+        self.user.corpus_right.filter(corpus=self.corpus).update(can_write=False, can_admin=False)
+        self.client.force_login(self.user)
+
+        with self.assertNumQueries(3):
+            response = self.client.delete(reverse('api:elements-children', kwargs={'pk': self.vol.id}))
+            self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+        self.assertFalse(delay_mock.called)
+
+    @patch('arkindex.project.triggers.tasks.element_trash.delay')
+    def test_destroy_element_children_empty(self, delay_mock):
+        self.client.force_login(self.user)
+        element = self.corpus.elements.create(type=self.volume_type, name='Lonely element')
+
+        with self.assertNumQueries(4):
+            response = self.client.delete(reverse('api:elements-children', kwargs={'pk': element.id}))
+            self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+        self.assertFalse(delay_mock.called)
+
+    @patch('arkindex.project.triggers.tasks.element_trash.delay')
+    def test_destroy_element_children(self, delay_mock):
+        self.client.force_login(self.user)
+        with self.assertNumQueries(4):
+            response = self.client.delete(reverse('api:elements-children', kwargs={'pk': self.vol.id}))
+            self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
+
+        self.assertEqual(delay_mock.call_count, 1)
+        args, kwargs = delay_mock.call_args
+        self.assertEqual(len(args), 0)
+        self.assertCountEqual(
+            list(kwargs.pop('queryset')),
+            # Direct children of the volume
+            list(Element.objects.get_descending(self.vol.id).filter(paths__path__last=self.vol.id)),
+        )
+        self.assertDictEqual(kwargs, {'delete_children': True})
+
+    @patch('arkindex.project.triggers.tasks.element_trash.delay')
+    def test_destroy_element_children_delete_children(self, delay_mock):
+        self.client.force_login(self.user)
+        with self.assertNumQueries(4):
+            response = self.client.delete(
+                reverse('api:elements-children', kwargs={'pk': self.vol.id}),
+                QUERY_STRING='delete_children=false'
+            )
+            self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
+
+        self.assertEqual(delay_mock.call_count, 1)
+        args, kwargs = delay_mock.call_args
+        self.assertEqual(len(args), 0)
+        self.assertCountEqual(
+            list(kwargs.pop('queryset')),
+            # Direct children of the volume
+            list(Element.objects.get_descending(self.vol.id).filter(paths__path__last=self.vol.id)),
+        )
+        self.assertDictEqual(kwargs, {'delete_children': False})
+
+    @patch('arkindex.project.triggers.tasks.element_trash.delay')
+    def test_destroy_element_children_rejected_filters(self, delay_mock):
+        self.client.force_login(self.user)
+        with self.assertNumQueries(2):
+            response = self.client.delete(
+                reverse('api:elements-children', kwargs={'pk': self.vol.id}),
+                QUERY_STRING='with_best_classes=True&with_has_children=True&best_class=True',
+                HTTP_IF_MODIFIED_SINCE='Thu, 02 Apr 2099 13:37:42 GMT',
+            )
+            self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
+        self.assertDictEqual(response.json(), {
+            'If-Modified-Since': ['This parameter is not allowed in DELETE requests.'],
+            'best_class': ['This parameter is not allowed in DELETE requests.'],
+            'with_best_classes': ['This parameter is not allowed in DELETE requests.'],
+            'with_has_children': ['This parameter is not allowed in DELETE requests.'],
+        })
+
+        self.assertFalse(delay_mock.called)
+
+    @patch('arkindex.project.triggers.tasks.element_trash.delay')
+    def test_destroy_element_parents_requires_login(self, delay_mock):
+        with self.assertNumQueries(0):
+            response = self.client.delete(reverse('api:elements-parents', kwargs={'pk': self.surface.id}))
+            self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+        self.assertFalse(delay_mock.called)
+
+    @patch('arkindex.project.triggers.tasks.element_trash.delay')
+    def test_destroy_element_parents_requires_writable(self, delay_mock):
+        self.user.corpus_right.filter(corpus=self.corpus).update(can_write=False, can_admin=False)
+        self.client.force_login(self.user)
+
+        with self.assertNumQueries(3):
+            response = self.client.delete(reverse('api:elements-parents', kwargs={'pk': self.surface.id}))
+            self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+        self.assertFalse(delay_mock.called)
+
+    @patch('arkindex.project.triggers.tasks.element_trash.delay')
+    def test_destroy_element_parents_empty(self, delay_mock):
+        self.client.force_login(self.user)
+        element = self.corpus.elements.create(type=self.volume_type, name='Lonely element')
+
+        with self.assertNumQueries(4):
+            response = self.client.delete(reverse('api:elements-parents', kwargs={'pk': element.id}))
+            self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+        self.assertFalse(delay_mock.called)
+
+    @patch('arkindex.project.triggers.tasks.element_trash.delay')
+    def test_destroy_element_parents(self, delay_mock):
+        self.client.force_login(self.user)
+        with self.assertNumQueries(4):
+            response = self.client.delete(reverse('api:elements-parents', kwargs={'pk': self.surface.id}))
+            self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
+
+        self.assertEqual(delay_mock.call_count, 1)
+        args, kwargs = delay_mock.call_args
+        self.assertEqual(len(args), 0)
+        self.assertCountEqual(
+            list(kwargs.pop('queryset')),
+            # Direct parents of the surface
+            list(Element.objects.get_ascending(self.surface.id, recursive=False)),
+        )
+        self.assertDictEqual(kwargs, {'delete_children': True})
+
+    @patch('arkindex.project.triggers.tasks.element_trash.delay')
+    def test_destroy_element_parents_delete_parents(self, delay_mock):
+        self.client.force_login(self.user)
+        with self.assertNumQueries(4):
+            response = self.client.delete(
+                reverse('api:elements-parents', kwargs={'pk': self.surface.id}),
+                QUERY_STRING='delete_children=false'
+            )
+            self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
+
+        self.assertEqual(delay_mock.call_count, 1)
+        args, kwargs = delay_mock.call_args
+        self.assertEqual(len(args), 0)
+        self.assertCountEqual(
+            list(kwargs.pop('queryset')),
+            # Direct parents of the surface
+            list(Element.objects.get_ascending(self.surface.id, recursive=False)),
+        )
+        self.assertDictEqual(kwargs, {'delete_children': False})
+
+    @patch('arkindex.project.triggers.tasks.element_trash.delay')
+    def test_destroy_element_parents_rejected_filters(self, delay_mock):
+        self.client.force_login(self.user)
+        with self.assertNumQueries(2):
+            response = self.client.delete(
+                reverse('api:elements-parents', kwargs={'pk': self.surface.id}),
+                QUERY_STRING='with_best_classes=True&with_has_children=True&best_class=True',
+                HTTP_IF_MODIFIED_SINCE='Thu, 02 Apr 2099 13:37:42 GMT',
+            )
+            self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
+        self.assertDictEqual(response.json(), {
+            'If-Modified-Since': ['This parameter is not allowed in DELETE requests.'],
+            'best_class': ['This parameter is not allowed in DELETE requests.'],
+            'with_best_classes': ['This parameter is not allowed in DELETE requests.'],
+            'with_has_children': ['This parameter is not allowed in DELETE requests.'],
+        })
+
+        self.assertFalse(delay_mock.called)
diff --git a/arkindex/documents/tests/test_parents_elements.py b/arkindex/documents/tests/test_parents_elements.py
index 6ad84a8a1861ca99e505cedc2ff0cfdd8c989f88..3e65fb504b5b889e4c912ab7067477f25b2439bf 100644
--- a/arkindex/documents/tests/test_parents_elements.py
+++ b/arkindex/documents/tests/test_parents_elements.py
@@ -76,7 +76,7 @@ class TestParentsElements(FixtureAPITestCase):
         """
         self.corpus.elements.filter(name__contains='Volume 1').update(worker_version=self.worker_version)
 
-        with self.assertNumQueries(6):
+        with self.assertNumQueries(7):
             response = self.client.get(
                 reverse('api:elements-parents', kwargs={'pk': str(self.page.id)}),
                 data={'worker_version': str(self.worker_version.id)},
@@ -89,7 +89,7 @@ class TestParentsElements(FixtureAPITestCase):
         )
 
     def test_parents_worker_version_validation(self):
-        with self.assertNumQueries(0):
+        with self.assertNumQueries(1):
             response = self.client.get(
                 reverse('api:elements-parents', kwargs={'pk': str(self.page.id)}),
                 data={'worker_version': 'blah'},
@@ -97,7 +97,7 @@ class TestParentsElements(FixtureAPITestCase):
             self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
         self.assertEqual(response.json(), {'worker_version': ['Invalid UUID']})
 
-        with self.assertNumQueries(1):
+        with self.assertNumQueries(2):
             response = self.client.get(
                 reverse('api:elements-parents', kwargs={'pk': str(self.page.id)}),
                 data={'worker_version': uuid.uuid4()},
@@ -107,7 +107,7 @@ class TestParentsElements(FixtureAPITestCase):
 
     def test_parents_with_has_children(self):
         surface = Element.objects.get(name='Surface A')
-        with self.assertNumQueries(7):
+        with self.assertNumQueries(8):
             response = self.client.get(
                 reverse('api:elements-parents', kwargs={'pk': str(surface.id)}),
                 data={'recursive': True, 'with_has_children': True},
@@ -122,7 +122,7 @@ class TestParentsElements(FixtureAPITestCase):
         )
 
     def test_parents_modified_since_bad_format(self):
-        with self.assertNumQueries(0):
+        with self.assertNumQueries(1):
             response = self.client.get(
                 reverse('api:elements-parents', kwargs={'pk': str(self.page.id)}),
                 HTTP_IF_MODIFIED_SINCE='AAAAAAAAAAAAAAAAA',
@@ -131,7 +131,7 @@ class TestParentsElements(FixtureAPITestCase):
         self.assertDictEqual(response.json(), {'If-Modified-Since': ['Bad date format']})
 
     def test_parents_modified_since_not_modified(self):
-        with self.assertNumQueries(1):
+        with self.assertNumQueries(2):
             response = self.client.get(
                 reverse('api:elements-parents', kwargs={'pk': str(self.page.id)}),
                 HTTP_IF_MODIFIED_SINCE='Thu, 02 Apr 2099 13:37:42 GMT',
@@ -140,7 +140,7 @@ class TestParentsElements(FixtureAPITestCase):
 
     def test_parents_modified_since(self):
         self.corpus.elements.filter(name='Volume 1').update(updated='2099-04-02T13:37:43Z')
-        with self.assertNumQueries(6):
+        with self.assertNumQueries(7):
             response = self.client.get(
                 reverse('api:elements-parents', kwargs={'pk': str(self.page.id)}),
                 HTTP_IF_MODIFIED_SINCE='Thu, 02 Apr 2099 13:37:42 GMT',
diff --git a/arkindex/project/openapi/patch.yml b/arkindex/project/openapi/patch.yml
index 66cc7c59dabebda8d6f4c77baa00f05f7ef64622..60a825fc83ea4b2eaead8a51ff820cd21f24d429 100644
--- a/arkindex/project/openapi/patch.yml
+++ b/arkindex/project/openapi/patch.yml
@@ -49,6 +49,10 @@ paths:
       security: []
     post:
       description: Create a new corpus
+  /api/v1/corpus/{corpus}/elements/:
+    delete:
+      operationId: DestroyElements
+      description: Delete elements in bulk
   /api/v1/corpus/{id}/:
     get:
       description: Retrieve a single corpus
@@ -116,6 +120,14 @@ paths:
       # Will need https://gitlab.com/arkindex/backend/-/issues/86 to be removed
       operationId: DestroyElementMLResults
       description: Delete machine learning results on an element and its direct children.
+  /api/v1/elements/{id}/children/:
+    delete:
+      operationId: DestroyElementChildren
+      description: Delete child elements in bulk
+  /api/v1/elements/{id}/parents/:
+    delete:
+      operationId: DestroyElementParents
+      description: Delete parent elements in bulk
   /api/v1/elements/selection/:
     delete:
       operationId: RemoveSelection
diff --git a/arkindex/project/openapi/schemas.py b/arkindex/project/openapi/schemas.py
index ae8c9c3e0c5d816fa4a92da39f49f8d10d007def..a813b80fb83a64938682db47bbe6cc834a1238b3 100644
--- a/arkindex/project/openapi/schemas.py
+++ b/arkindex/project/openapi/schemas.py
@@ -82,8 +82,13 @@ class AutoSchema(BaseAutoSchema):
             # Avoid removing the parameter overrides with .pop()
             overrides = self.view.openapi_overrides.copy()
             if 'parameters' in overrides:
+                # Filter parameters by HTTP method
+                allowed_parameters = [
+                    parameter for parameter in overrides.pop('parameters')
+                    if 'x-methods' not in parameter or method.lower() in parameter['x-methods']
+                ]
                 # Append parameters instead of replacing
-                operation.setdefault('parameters', []).extend(overrides.pop('parameters'))
+                operation.setdefault('parameters', []).extend(allowed_parameters)
             operation.update(overrides)
 
         return operation
diff --git a/arkindex/project/tests/openapi/test_schemas.py b/arkindex/project/tests/openapi/test_schemas.py
index dc34385c80e49ac577e62072ecac45cba885ed9a..79ef30012f40e174449906840c9c348eb302e490 100644
--- a/arkindex/project/tests/openapi/test_schemas.py
+++ b/arkindex/project/tests/openapi/test_schemas.py
@@ -155,6 +155,107 @@ class TestAutoSchema(TestCase):
             }
         )
 
+    def test_overrides_method_filter(self):
+        """
+        Test the `x-methods` filter for parameters on openapi_overrides
+        """
+
+        class ThingView(APIView):
+            action = 'Retrieve'
+            openapi_overrides = {
+                'parameters': [
+                    {
+                        'description': 'Some extra parameter',
+                        'in': 'query',
+                        'name': 'something_get',
+                        'required': False,
+                        'schema': {'type': 'integer'},
+                        'x-methods': ['get'],
+                    },
+                    {
+                        'description': 'Some extra parameter',
+                        'in': 'query',
+                        'name': 'something_post',
+                        'required': False,
+                        'schema': {'type': 'integer'},
+                        'x-methods': ['post', 'delete'],
+                    },
+                    {
+                        'description': 'Some extra parameter',
+                        'in': 'query',
+                        'name': 'something',
+                        'required': False,
+                        'schema': {'type': 'integer'},
+                    }
+                ],
+            }
+
+            def get(self, *args, **kwargs):
+                pass
+
+            def post(self, *args, **kwargs):
+                pass
+
+        inspector = AutoSchema()
+        inspector.view = create_view(ThingView, 'GET', create_request('/test/{id}/'))
+        self.assertListEqual(
+            inspector.get_operation('/test/{id}/', 'GET')['parameters'],
+            [
+                {
+                    'description': '',
+                    'in': 'path',
+                    'name': 'id',
+                    'required': True,
+                    'schema': {'type': 'string'},
+                },
+                {
+                    'description': 'Some extra parameter',
+                    'in': 'query',
+                    'name': 'something_get',
+                    'required': False,
+                    'schema': {'type': 'integer'},
+                    'x-methods': ['get'],
+                },
+                {
+                    'description': 'Some extra parameter',
+                    'in': 'query',
+                    'name': 'something',
+                    'required': False,
+                    'schema': {'type': 'integer'},
+                }
+            ],
+        )
+
+        inspector = AutoSchema()
+        inspector.view = create_view(ThingView, 'POST', create_request('/test/{id}/'))
+        self.assertListEqual(
+            inspector.get_operation('/test/{id}/', 'POST')['parameters'],
+            [
+                {
+                    'description': '',
+                    'in': 'path',
+                    'name': 'id',
+                    'required': True,
+                    'schema': {'type': 'string'},
+                },
+                {
+                    'description': 'Some extra parameter',
+                    'in': 'query',
+                    'name': 'something_post',
+                    'required': False,
+                    'schema': {'type': 'integer'},
+                    'x-methods': ['post', 'delete'],
+                },
+                {
+                    'description': 'Some extra parameter',
+                    'in': 'query',
+                    'name': 'something',
+                    'required': False,
+                    'schema': {'type': 'integer'},
+                }
+            ],
+        )
+
     def test_bugfix_list_uppercase(self):
         """
         Test list API views have title-cased endpoint names
diff --git a/arkindex/project/triggers.py b/arkindex/project/triggers.py
index a975001155e581fb3ceb21c9021ae4b243d2c440..b95538b071a0ec571b6beaa5f7ee8eafb19cce8e 100644
--- a/arkindex/project/triggers.py
+++ b/arkindex/project/triggers.py
@@ -7,6 +7,7 @@ from uuid import UUID
 from django.conf import settings
 
 from arkindex.documents import tasks
+from arkindex.documents.managers import ElementQuerySet
 from arkindex.documents.models import Corpus, Element, Entity
 
 
@@ -102,3 +103,11 @@ def corpus_delete(corpus: Union[Corpus, UUID, str]) -> None:
         corpus_id = str(corpus)
 
     tasks.corpus_delete.delay(corpus_id=corpus_id)
+
+
+def element_trash(queryset: ElementQuerySet, delete_children: bool = True) -> None:
+    """
+    Run ElementQuerySet.trash to delete a batch of elements.
+    """
+    assert isinstance(queryset, ElementQuerySet), 'Only Element querysets can be trashed'
+    tasks.element_trash.delay(queryset=queryset, delete_children=delete_children)