From f75ed23f4d1b9799baa8c051b7416032ac971c45 Mon Sep 17 00:00:00 2001
From: Petr Stribny <petr@stribny.name>
Date: Tue, 19 Jul 2022 21:00:07 +0000
Subject: [PATCH] Batch webhooks

---
 backend/src/baserow/config/settings/base.py   |   2 +-
 .../contrib/database/api/rows/serializers.py  |  21 +-
 .../database/api/webhooks/serializers.py      |   8 +-
 backend/src/baserow/contrib/database/apps.py  |   6 +
 .../migrations/0081_batch_webhooks.py         |  46 ++
 .../baserow/contrib/database/rows/actions.py  |   2 +-
 .../baserow/contrib/database/rows/handler.py  |  44 +-
 .../baserow/contrib/database/rows/signals.py  |   5 -
 .../database/rows/webhook_event_types.py      | 146 ++++--
 .../contrib/database/trash/trash_types.py     |   6 +-
 .../baserow/contrib/database/views/handler.py |   6 +-
 .../contrib/database/webhooks/handler.py      |  15 +-
 .../contrib/database/webhooks/registries.py   |  23 +-
 .../database/ws/public/rows/signals.py        | 186 +------
 .../contrib/database/ws/rows/signals.py       | 119 -----
 .../baserow/contrib/database/ws/signals.py    |  20 +-
 .../baserow/test_utils/fixtures/webhook.py    |   2 +-
 .../api/webhooks/test_webhook_views.py        |  26 +-
 .../rows/test_row_webhook_event_types.py      | 136 +++---
 .../database/rows/test_rows_handler.py        |  33 +-
 .../trash/test_database_trash_types.py        |  22 +-
 .../database/view/test_view_handler.py        |   4 +-
 .../database/webhooks/test_webhook_handler.py |  68 +--
 .../webhooks/test_webhook_registries.py       |  12 +-
 .../database/webhooks/test_webhook_tasks.py   |  30 +-
 .../ws/public/test_public_ws_rows_signals.py  | 258 +++++-----
 .../database/ws/test_ws_rows_signals.py       |  66 +--
 changelog.md                                  |   7 +-
 docs/apis/web-socket-api.md                   |   6 +-
 web-frontend/locales/en.json                  |   5 +-
 .../core/assets/scss/components/webhook.scss  |   5 +
 .../components/webhook/WebhookForm.vue        | 452 ++++++++++--------
 web-frontend/modules/database/locales/en.json |   5 +
 web-frontend/modules/database/plugin.js       |  12 +-
 web-frontend/modules/database/realtime.js     |  52 --
 .../modules/database/webhookEventTypes.js     |  28 +-
 36 files changed, 897 insertions(+), 987 deletions(-)
 create mode 100644 backend/src/baserow/contrib/database/migrations/0081_batch_webhooks.py

diff --git a/backend/src/baserow/config/settings/base.py b/backend/src/baserow/config/settings/base.py
index 4648a6aa7..4cfd9ed3e 100644
--- a/backend/src/baserow/config/settings/base.py
+++ b/backend/src/baserow/config/settings/base.py
@@ -387,7 +387,7 @@ SPECTACULAR_SETTINGS = {
             "multiple_select_has",
             "multiple_select_has_not",
         ],
-        "EventTypesEnum": ["row.created", "row.updated", "row.deleted"],
+        "EventTypesEnum": ["rows.created", "rows.updated", "rows.deleted"],
     },
 }
 
diff --git a/backend/src/baserow/contrib/database/api/rows/serializers.py b/backend/src/baserow/contrib/database/api/rows/serializers.py
index d263992f7..c054202df 100644
--- a/backend/src/baserow/contrib/database/api/rows/serializers.py
+++ b/backend/src/baserow/contrib/database/api/rows/serializers.py
@@ -1,6 +1,6 @@
 import logging
 from copy import deepcopy
-from typing import Dict
+from typing import Dict, List
 
 from django.conf import settings
 from rest_framework import serializers
@@ -291,7 +291,24 @@ def get_example_row_metadata_field_serializer():
     )
 
 
-def remap_serialized_row_to_user_field_names(serialized_row: Dict, model: ModelBase):
+def remap_serialized_rows_to_user_field_names(
+    serialized_rows: List[Dict], model: ModelBase
+) -> List[Dict]:
+    """
+    Remap the values of rows from field ids to the user defined field names.
+
+    :param serialized_rows: The rows whose fields to remap.
+    :param model: The model for which to generate a serializer.
+    """
+
+    return [
+        remap_serialized_row_to_user_field_names(row, model) for row in serialized_rows
+    ]
+
+
+def remap_serialized_row_to_user_field_names(
+    serialized_row: Dict, model: ModelBase
+) -> Dict:
     """
     Remap the values of a row from field ids to the user defined field names.
 
diff --git a/backend/src/baserow/contrib/database/api/webhooks/serializers.py b/backend/src/baserow/contrib/database/api/webhooks/serializers.py
index 661a0838a..1c580849d 100644
--- a/backend/src/baserow/contrib/database/api/webhooks/serializers.py
+++ b/backend/src/baserow/contrib/database/api/webhooks/serializers.py
@@ -53,7 +53,13 @@ class TableWebhookCreateRequestSerializer(serializers.ModelSerializer):
 class TableWebhookUpdateRequestSerializer(serializers.ModelSerializer):
     events = serializers.ListField(
         required=False,
-        child=serializers.ChoiceField(choices=webhook_event_type_registry.get_types()),
+        child=serializers.ChoiceField(
+            choices=[
+                t
+                for t in webhook_event_type_registry.get_types()
+                if t not in ["row.created", "row.updated", "row.deleted"]
+            ]
+        ),
         help_text="A list containing the events that will trigger this webhook.",
     )
     headers = serializers.DictField(
diff --git a/backend/src/baserow/contrib/database/apps.py b/backend/src/baserow/contrib/database/apps.py
index dd2e226b2..565ef70c3 100644
--- a/backend/src/baserow/contrib/database/apps.py
+++ b/backend/src/baserow/contrib/database/apps.py
@@ -348,13 +348,19 @@ class DatabaseConfig(AppConfig):
         register_formula_functions(formula_function_registry)
 
         from .rows.webhook_event_types import (
+            RowsCreatedEventType,
             RowCreatedEventType,
+            RowsUpdatedEventType,
             RowUpdatedEventType,
+            RowsDeletedEventType,
             RowDeletedEventType,
         )
 
+        webhook_event_type_registry.register(RowsCreatedEventType())
         webhook_event_type_registry.register(RowCreatedEventType())
+        webhook_event_type_registry.register(RowsUpdatedEventType())
         webhook_event_type_registry.register(RowUpdatedEventType())
+        webhook_event_type_registry.register(RowsDeletedEventType())
         webhook_event_type_registry.register(RowDeletedEventType())
 
         from .airtable.airtable_column_types import (
diff --git a/backend/src/baserow/contrib/database/migrations/0081_batch_webhooks.py b/backend/src/baserow/contrib/database/migrations/0081_batch_webhooks.py
new file mode 100644
index 000000000..ed124e85f
--- /dev/null
+++ b/backend/src/baserow/contrib/database/migrations/0081_batch_webhooks.py
@@ -0,0 +1,46 @@
+# Generated by Django 3.2.13 on 2022-07-04 15:16
+
+from django.db import migrations, transaction
+from baserow.contrib.database.models import TableWebhook, TableWebhookEvent
+
+
+def forward(apps, schema_editor):
+    """
+    This migration will create individual TableWebhookEvent entries
+    for all deprecated single row webhooks that has include_all_events
+    set to True.
+    """
+
+    with transaction.atomic():
+        webhooks = TableWebhook.objects.filter(include_all_events=True)
+        create_webhooks = []
+        for webhook in webhooks:
+            create_webhooks.append(
+                TableWebhookEvent(webhook=webhook, event_type="row.created")
+            )
+            create_webhooks.append(
+                TableWebhookEvent(webhook=webhook, event_type="row.updated")
+            )
+            create_webhooks.append(
+                TableWebhookEvent(webhook=webhook, event_type="row.deleted")
+            )
+
+        TableWebhookEvent.objects.bulk_create(create_webhooks, batch_size=100)
+        TableWebhook.objects.filter(include_all_events=True).update(
+            include_all_events=False
+        )
+
+
+def reverse(apps, schema_editor):
+    ...
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("database", "0080_auto_20220702_1612"),
+    ]
+
+    operations = [
+        migrations.RunPython(forward, reverse),
+    ]
diff --git a/backend/src/baserow/contrib/database/rows/actions.py b/backend/src/baserow/contrib/database/rows/actions.py
index 18874252f..30c053c3b 100644
--- a/backend/src/baserow/contrib/database/rows/actions.py
+++ b/backend/src/baserow/contrib/database/rows/actions.py
@@ -41,7 +41,7 @@ class CreateRowActionType(ActionType):
     ) -> GeneratedTableModel:
         """
         Creates a new row for a given table with the provided values if the user
-        belongs to the related group. It also calls the row_created signal.
+        belongs to the related group. It also calls the rows_created signal.
         See the baserow.contrib.database.rows.handler.RowHandler.create_row
         for more information.
         Undoing this action trashes the row and redoing restores it.
diff --git a/backend/src/baserow/contrib/database/rows/handler.py b/backend/src/baserow/contrib/database/rows/handler.py
index 52156bfc7..76532451a 100644
--- a/backend/src/baserow/contrib/database/rows/handler.py
+++ b/backend/src/baserow/contrib/database/rows/handler.py
@@ -22,15 +22,10 @@ from baserow.core.trash.handler import TrashHandler
 from baserow.core.utils import get_non_unique_values
 from .exceptions import RowDoesNotExist, RowIdsNotUnique
 from .signals import (
-    before_row_update,
-    before_row_delete,
     before_rows_update,
     before_rows_delete,
-    row_created,
     rows_created,
-    row_updated,
     rows_updated,
-    row_deleted,
     rows_deleted,
 )
 
@@ -404,7 +399,7 @@ class RowHandler:
     ) -> GeneratedTableModel:
         """
         Creates a new row for a given table with the provided values if the user
-        belongs to the related group. It also calls the row_created signal.
+        belongs to the related group. It also calls the rows_created signal.
 
         :param user: The user of whose behalf the row is created.
         :param table: The table for which to create a row for.
@@ -429,8 +424,13 @@ class RowHandler:
             table, values, model, before_row, user_field_names
         )
 
-        row_created.send(
-            self, row=instance, before=before_row, user=user, table=table, model=model
+        rows_created.send(
+            self,
+            rows=[instance],
+            before=before_row,
+            user=user,
+            table=table,
+            model=model,
         )
 
         return instance
@@ -617,14 +617,15 @@ class RowHandler:
                 updated_fields_by_name[field["name"]] = field["field"]
                 updated_fields.append(field["field"])
 
-        before_return = before_row_update.send(
+        before_return = before_rows_update.send(
             self,
-            row=row,
+            rows=[row],
             user=user,
             table=table,
             model=model,
             updated_field_ids=updated_field_ids,
         )
+
         values = self.prepare_values(model._field_objects, values)
         values, manytomany_values = self.extract_manytomany_values(values, model)
 
@@ -683,9 +684,9 @@ class RowHandler:
 
         ViewHandler().field_value_updated(updated_fields)
 
-        row_updated.send(
+        rows_updated.send(
             self,
-            row=row,
+            rows=[row],
             user=user,
             table=table,
             model=model,
@@ -1165,8 +1166,8 @@ class RowHandler:
         if model is None:
             model = table.get_model()
 
-        before_return = before_row_update.send(
-            self, row=row, user=user, table=table, model=model, updated_field_ids=[]
+        before_return = before_rows_update.send(
+            self, rows=[row], user=user, table=table, model=model, updated_field_ids=[]
         )
 
         row.order = self.get_order_before_row(before_row, model)[0]
@@ -1204,9 +1205,9 @@ class RowHandler:
 
         ViewHandler().field_value_updated(updated_fields)
 
-        row_updated.send(
+        rows_updated.send(
             self,
-            row=row,
+            rows=[row],
             user=user,
             table=table,
             model=model,
@@ -1264,12 +1265,10 @@ class RowHandler:
         if model is None:
             model = table.get_model()
 
-        before_return = before_row_delete.send(
-            self, row=row, user=user, table=table, model=model
+        before_return = before_rows_delete.send(
+            self, rows=[row], user=user, table=table, model=model
         )
 
-        row_id = row.id
-
         TrashHandler.trash(user, group, table.database, row, parent_id=table.id)
 
         update_collector = FieldUpdateCollector(table, starting_row_ids=[row.id])
@@ -1305,10 +1304,9 @@ class RowHandler:
 
         ViewHandler().field_value_updated(updated_fields)
 
-        row_deleted.send(
+        rows_deleted.send(
             self,
-            row_id=row_id,
-            row=row,
+            rows=[row],
             user=user,
             table=table,
             model=model,
diff --git a/backend/src/baserow/contrib/database/rows/signals.py b/backend/src/baserow/contrib/database/rows/signals.py
index caf2d2f33..6955ecdc2 100644
--- a/backend/src/baserow/contrib/database/rows/signals.py
+++ b/backend/src/baserow/contrib/database/rows/signals.py
@@ -3,14 +3,9 @@ from django.dispatch import Signal
 
 # Note that it could happen that this signal is triggered, but the actual update still
 # fails because of a validation error.
-before_row_update = Signal()
-before_row_delete = Signal()
 before_rows_update = Signal()
 before_rows_delete = Signal()
 
-row_created = Signal()
 rows_created = Signal()
-row_updated = Signal()
 rows_updated = Signal()
-row_deleted = Signal()
 rows_deleted = Signal()
diff --git a/backend/src/baserow/contrib/database/rows/webhook_event_types.py b/backend/src/baserow/contrib/database/rows/webhook_event_types.py
index 1b6625caf..148a2f45e 100644
--- a/backend/src/baserow/contrib/database/rows/webhook_event_types.py
+++ b/backend/src/baserow/contrib/database/rows/webhook_event_types.py
@@ -1,14 +1,14 @@
 from baserow.contrib.database.api.rows.serializers import (
     get_row_serializer_class,
-    remap_serialized_row_to_user_field_names,
+    remap_serialized_rows_to_user_field_names,
     RowSerializer,
 )
 from baserow.contrib.database.webhooks.registries import WebhookEventType
-from baserow.contrib.database.ws.rows.signals import before_row_update
-from .signals import row_created, row_updated, row_deleted
+from baserow.contrib.database.ws.rows.signals import before_rows_update
+from .signals import rows_created, rows_updated, rows_deleted
 
 
-class RowEventType(WebhookEventType):
+class RowsEventType(WebhookEventType):
     def get_row_serializer(self, webhook, model):
         return get_row_serializer_class(
             model,
@@ -17,26 +17,67 @@ class RowEventType(WebhookEventType):
             user_field_names=webhook.use_user_field_names,
         )
 
-    def get_payload(self, event_id, webhook, model, table, row, **kwargs):
+    def get_payload(self, event_id, webhook, model, table, rows, **kwargs):
         payload = super().get_payload(event_id, webhook, **kwargs)
-        payload["row_id"] = row.id
-        payload["values"] = self.get_row_serializer(webhook, model)(row).data
+        payload["items"] = self.get_row_serializer(webhook, model)(rows, many=True).data
         return payload
 
 
-class RowCreatedEventType(RowEventType):
+class RowsCreatedEventType(RowsEventType):
+    type = "rows.created"
+    signal = rows_created
+
+    def get_test_call_payload(self, table, model, event_id, webhook):
+        rows = [model(id=0, order=0)]
+        payload = self.get_payload(
+            event_id=event_id,
+            webhook=webhook,
+            model=model,
+            table=table,
+            rows=rows,
+        )
+        return payload
+
+
+class RowCreatedEventType(RowsCreatedEventType):
+    """
+    Handling of deprecated single row.created webhook
+    """
+
     type = "row.created"
-    signal = row_created
+    signal = rows_created
+
+    def get_payload(self, *args, **kwargs):
+        payload = super().get_payload(*args, **kwargs)
+        payload["row_id"] = payload["items"][0]["id"]
+        payload["values"] = payload["items"][0]
+        del payload["items"]
+        return payload
 
 
-class RowUpdatedEventType(RowEventType):
-    type = "row.updated"
-    signal = row_updated
+class RowsUpdatedEventType(RowsEventType):
+    type = "rows.updated"
+    signal = rows_updated
 
-    def get_test_call_before_return(self, table, row, model):
-        return {
-            before_row_update: before_row_update(
-                row=row,
+    def get_payload(
+        self, event_id, webhook, model, table, rows, before_return, **kwargs
+    ):
+        payload = super().get_payload(event_id, webhook, model, table, rows, **kwargs)
+
+        old_items = dict(before_return)[before_rows_update]
+
+        if webhook.use_user_field_names:
+            old_items = remap_serialized_rows_to_user_field_names(old_items, model)
+
+        payload["old_items"] = old_items
+
+        return payload
+
+    def get_test_call_payload(self, table, model, event_id, webhook):
+        rows = [model(id=0, order=0)]
+        before_return = {
+            before_rows_update: before_rows_update(
+                rows=rows,
                 model=model,
                 sender=None,
                 user=None,
@@ -44,26 +85,71 @@ class RowUpdatedEventType(RowEventType):
                 updated_field_ids=None,
             )
         }
+        payload = self.get_payload(
+            event_id=event_id,
+            webhook=webhook,
+            model=model,
+            table=table,
+            rows=rows,
+            before_return=before_return,
+        )
+        return payload
+
+
+class RowUpdatedEventType(RowsUpdatedEventType):
+    """
+    Handling of deprecated single row.updated webhook
+    """
+
+    type = "row.updated"
+    signal = rows_updated
 
     def get_payload(
-        self, event_id, webhook, model, table, row, before_return, **kwargs
+        self, event_id, webhook, model, table, rows, before_return, **kwargs
     ):
-        payload = super().get_payload(event_id, webhook, model, table, row, **kwargs)
-        old_values = dict(before_return)[before_row_update]
-
-        if webhook.use_user_field_names:
-            old_values = remap_serialized_row_to_user_field_names(old_values, model)
-
-        payload["old_values"] = old_values
-
+        payload = super().get_payload(
+            event_id, webhook, model, table, rows, before_return, **kwargs
+        )
+        payload["row_id"] = payload["items"][0]["id"]
+        payload["values"] = payload["items"][0]
+        payload["old_values"] = payload["old_items"][0]
+        del payload["items"]
+        del payload["old_items"]
         return payload
 
 
-class RowDeletedEventType(WebhookEventType):
-    type = "row.deleted"
-    signal = row_deleted
+class RowsDeletedEventType(WebhookEventType):
+    type = "rows.deleted"
+    signal = rows_deleted
 
-    def get_payload(self, event_id, webhook, row, **kwargs):
+    def get_payload(self, event_id, webhook, rows, **kwargs):
         payload = super().get_payload(event_id, webhook, **kwargs)
-        payload["row_id"] = row.id
+        payload["row_ids"] = [row.id for row in rows]
+        return payload
+
+    def get_test_call_payload(self, table, model, event_id, webhook):
+        rows = [model(id=0, order=0)]
+
+        payload = self.get_payload(
+            event_id=event_id,
+            webhook=webhook,
+            model=model,
+            table=table,
+            rows=rows,
+        )
+        return payload
+
+
+class RowDeletedEventType(RowsDeletedEventType):
+    """
+    Handling of deprecated single row.deleted webhook
+    """
+
+    type = "row.deleted"
+    signal = rows_deleted
+
+    def get_payload(self, event_id, webhook, rows, **kwargs):
+        payload = super().get_payload(event_id, webhook, rows, **kwargs)
+        payload["row_id"] = rows[0].id
+        del payload["row_ids"]
         return payload
diff --git a/backend/src/baserow/contrib/database/trash/trash_types.py b/backend/src/baserow/contrib/database/trash/trash_types.py
index 89782b079..e74f9fb0e 100644
--- a/backend/src/baserow/contrib/database/trash/trash_types.py
+++ b/backend/src/baserow/contrib/database/trash/trash_types.py
@@ -10,7 +10,7 @@ from baserow.contrib.database.fields.dependencies.update_collector import (
 from baserow.contrib.database.fields.handler import FieldHandler
 from baserow.contrib.database.fields.models import Field
 from baserow.contrib.database.fields.registries import field_type_registry
-from baserow.contrib.database.rows.signals import row_created, rows_created
+from baserow.contrib.database.rows.signals import rows_created
 from baserow.contrib.database.table.models import Table, GeneratedTableModel
 from baserow.contrib.database.table.signals import table_created
 from baserow.contrib.database.views.handler import ViewHandler
@@ -258,9 +258,9 @@ class RowTrashableItemType(TrashableItemType):
 
         ViewHandler().field_value_updated(updated_fields)
 
-        row_created.send(
+        rows_created.send(
             self,
-            row=trashed_item,
+            rows=[trashed_item],
             table=table,
             model=model,
             before=None,
diff --git a/backend/src/baserow/contrib/database/views/handler.py b/backend/src/baserow/contrib/database/views/handler.py
index b5b64ef8b..4efb8942f 100644
--- a/backend/src/baserow/contrib/database/views/handler.py
+++ b/backend/src/baserow/contrib/database/views/handler.py
@@ -35,7 +35,7 @@ from baserow.contrib.database.fields.field_sortings import AnnotatedOrder
 from baserow.contrib.database.fields.models import Field
 from baserow.contrib.database.fields.registries import field_type_registry
 from baserow.contrib.database.rows.handler import RowHandler
-from baserow.contrib.database.rows.signals import row_created
+from baserow.contrib.database.rows.signals import rows_created
 from baserow.contrib.database.table.models import Table, GeneratedTableModel
 from baserow.core.trash.handler import TrashHandler
 from baserow.core.utils import (
@@ -1712,8 +1712,8 @@ class ViewHandler:
         allowed_values = extract_allowed(values, allowed_field_names)
         instance = RowHandler().force_create_row(table, allowed_values, model)
 
-        row_created.send(
-            self, row=instance, before=None, user=None, table=table, model=model
+        rows_created.send(
+            self, rows=[instance], before=None, user=None, table=table, model=model
         )
 
         return instance
diff --git a/backend/src/baserow/contrib/database/webhooks/handler.py b/backend/src/baserow/contrib/database/webhooks/handler.py
index 19a747fb5..9ac5d1ef7 100644
--- a/backend/src/baserow/contrib/database/webhooks/handler.py
+++ b/backend/src/baserow/contrib/database/webhooks/handler.py
@@ -365,19 +365,10 @@ class WebhookHandler:
 
         event_id = str(uuid.uuid4())
         model = table.get_model()
-        row = model(id=0, order=0)
+
         event = webhook_event_type_registry.get(event_type)
-        before_return = event.get_test_call_before_return(
-            table=table, row=row, model=model
-        )
-        payload = event.get_payload(
-            event_id=event_id,
-            webhook=webhook,
-            model=model,
-            table=table,
-            row=row,
-            before_return=before_return,
-        )
+
+        payload = event.get_test_call_payload(table, model, event_id, webhook)
         headers.update(self.get_headers(event_type, event_id))
 
         return self.make_request(webhook.request_method, webhook.url, headers, payload)
diff --git a/backend/src/baserow/contrib/database/webhooks/registries.py b/backend/src/baserow/contrib/database/webhooks/registries.py
index 50cefa8e0..237939933 100644
--- a/backend/src/baserow/contrib/database/webhooks/registries.py
+++ b/backend/src/baserow/contrib/database/webhooks/registries.py
@@ -39,8 +39,27 @@ class WebhookEventType(Instance):
         super().__init__()
         self.signal.connect(self.listener)
 
-    def get_test_call_before_return(self, **kwargs):
-        """Prepare a `before_return` value for a webhook event."""
+    def get_test_call_payload(self, table, model, event_id, webhook):
+        """
+        Constructs a test payload for a webhook call.
+
+        :param table: The table with changes.
+        :param model: The table's model.
+        :param event_id: The id of the event.
+        :param webhook: The webhook object related to the call.
+        :return: A JSON serializable dict with the test payload.
+        """
+
+        row = model(id=0, order=0)
+        payload = self.get_payload(
+            event_id=event_id,
+            webhook=webhook,
+            model=model,
+            table=table,
+            row=row,
+            before_return=None,
+        )
+        return payload
 
     def get_payload(self, event_id, webhook, **kwargs):
         """
diff --git a/backend/src/baserow/contrib/database/ws/public/rows/signals.py b/backend/src/baserow/contrib/database/ws/public/rows/signals.py
index f51dcefe7..9e8fadd1d 100644
--- a/backend/src/baserow/contrib/database/ws/public/rows/signals.py
+++ b/backend/src/baserow/contrib/database/ws/public/rows/signals.py
@@ -1,4 +1,4 @@
-from typing import Optional, Any, Dict, Iterable, List
+from typing import Optional, Any, Dict, List
 
 from django.db import transaction
 from django.dispatch import receiver
@@ -11,10 +11,8 @@ from baserow.contrib.database.api.rows.serializers import (
 from baserow.contrib.database.rows import signals as row_signals
 from baserow.contrib.database.table.models import GeneratedTableModel
 from baserow.contrib.database.views.handler import PublicViewRows, ViewHandler
-from baserow.contrib.database.views.models import View
 from baserow.contrib.database.views.registries import view_type_registry
 from baserow.contrib.database.ws.rows.signals import (
-    before_row_update,
     before_rows_update,
     RealtimeRowMessages,
 )
@@ -27,32 +25,6 @@ def _serialize_row(model, row, many=False):
     ).data
 
 
-def _send_row_created_event_to_views(
-    serialized_row: Dict[Any, Any],
-    before: Optional[GeneratedTableModel],
-    public_views: Iterable[View],
-):
-    view_page_type = page_registry.get("view")
-    handler = ViewHandler()
-    for public_view in public_views:
-        view_type = view_type_registry.get_by_model(public_view.specific_class)
-        if not view_type.when_shared_publicly_requires_realtime_events:
-            continue
-
-        restricted_serialized_row = handler.restrict_row_for_view(
-            public_view, serialized_row
-        )
-        view_page_type.broadcast(
-            RealtimeRowMessages.row_created(
-                table_id=PUBLIC_PLACEHOLDER_ENTITY_ID,
-                serialized_row=restricted_serialized_row,
-                metadata={},
-                before=before,
-            ),
-            slug=public_view.slug,
-        )
-
-
 def _send_rows_created_event_to_views(
     serialized_rows: List[Dict[Any, Any]],
     before: Optional[GeneratedTableModel],
@@ -80,28 +52,6 @@ def _send_rows_created_event_to_views(
         )
 
 
-def _send_row_deleted_event_to_views(
-    serialized_deleted_row: Dict[Any, Any], public_views: Iterable[View]
-):
-    view_page_type = page_registry.get("view")
-    handler = ViewHandler()
-    for public_view in public_views:
-        view_type = view_type_registry.get_by_model(public_view.specific_class)
-        if not view_type.when_shared_publicly_requires_realtime_events:
-            continue
-
-        restricted_serialized_deleted_row = handler.restrict_row_for_view(
-            public_view, serialized_deleted_row
-        )
-        view_page_type.broadcast(
-            RealtimeRowMessages.row_deleted(
-                table_id=PUBLIC_PLACEHOLDER_ENTITY_ID,
-                serialized_row=restricted_serialized_deleted_row,
-            ),
-            slug=public_view.slug,
-        )
-
-
 def _send_rows_deleted_event_to_views(
     serialized_deleted_rows: List[Dict[Any, Any]],
     public_views: List[PublicViewRows],
@@ -125,20 +75,6 @@ def _send_rows_deleted_event_to_views(
         )
 
 
-@receiver(row_signals.row_created)
-def public_row_created(sender, row, before, user, table, model, **kwargs):
-    row_checker = ViewHandler().get_public_views_row_checker(
-        table, model, only_include_views_which_want_realtime_events=True
-    )
-    transaction.on_commit(
-        lambda: _send_row_created_event_to_views(
-            _serialize_row(model, row),
-            before,
-            row_checker.get_public_views_where_row_is_visible(row),
-        ),
-    )
-
-
 @receiver(row_signals.rows_created)
 def public_rows_created(sender, rows, before, user, table, model, **kwargs):
     row_checker = ViewHandler().get_public_views_row_checker(
@@ -153,19 +89,6 @@ def public_rows_created(sender, rows, before, user, table, model, **kwargs):
     )
 
 
-@receiver(row_signals.before_row_delete)
-def public_before_row_delete(sender, row, user, table, model, **kwargs):
-    row_checker = ViewHandler().get_public_views_row_checker(
-        table, model, only_include_views_which_want_realtime_events=True
-    )
-    return {
-        "deleted_row_public_views": (
-            row_checker.get_public_views_where_row_is_visible(row)
-        ),
-        "deleted_row": _serialize_row(model, row),
-    }
-
-
 @receiver(row_signals.before_rows_delete)
 def public_before_rows_delete(sender, rows, user, table, model, **kwargs):
     row_checker = ViewHandler().get_public_views_row_checker(
@@ -179,21 +102,6 @@ def public_before_rows_delete(sender, rows, user, table, model, **kwargs):
     }
 
 
-@receiver(row_signals.row_deleted)
-def public_row_deleted(
-    sender, row_id, row, user, table, model, before_return, **kwargs
-):
-    public_views = dict(before_return)[public_before_row_delete][
-        "deleted_row_public_views"
-    ]
-    serialized_deleted_row = dict(before_return)[public_before_row_delete][
-        "deleted_row"
-    ]
-    transaction.on_commit(
-        lambda: _send_row_deleted_event_to_views(serialized_deleted_row, public_views)
-    )
-
-
 @receiver(row_signals.rows_deleted)
 def public_rows_deleted(sender, rows, user, table, model, before_return, **kwargs):
     public_views = dict(before_return)[public_before_rows_delete][
@@ -207,25 +115,6 @@ def public_rows_deleted(sender, rows, user, table, model, before_return, **kwarg
     )
 
 
-@receiver(row_signals.before_row_update)
-def public_before_row_update(
-    sender, row, user, table, model, updated_field_ids, **kwargs
-):
-    # Generate a serialized version of the row before it is updated. The
-    # `row_updated` receiver needs this serialized version because it can't serialize
-    # the old row after it has been updated.
-    row_checker = ViewHandler().get_public_views_row_checker(
-        table,
-        model,
-        only_include_views_which_want_realtime_events=True,
-        updated_field_ids=updated_field_ids,
-    )
-    return {
-        "old_row_public_views": row_checker.get_public_views_where_row_is_visible(row),
-        "caching_row_checker": row_checker,
-    }
-
-
 @receiver(row_signals.before_rows_update)
 def public_before_rows_update(
     sender, rows, user, table, model, updated_field_ids, **kwargs
@@ -244,73 +133,6 @@ def public_before_rows_update(
     }
 
 
-@receiver(row_signals.row_updated)
-def public_row_updated(
-    sender, row, user, table, model, before_return, updated_field_ids, **kwargs
-):
-    before_return_dict = dict(before_return)[public_before_row_update]
-    serialized_old_row = dict(before_return)[before_row_update]
-    serialized_updated_row = _serialize_row(model, row)
-
-    old_row_public_views = before_return_dict["old_row_public_views"]
-    existing_checker = before_return_dict["caching_row_checker"]
-    views = existing_checker.get_public_views_where_row_is_visible(row)
-    updated_row_public_views = {view.slug: view for view in views}
-
-    # When a row is updated from the point of view of a public view it might not always
-    # result in a `row_updated` event. For example if the row was previously not visible
-    # in the public view due to its filters, but the row update makes it now match
-    # the filters we want to send a `row_created` event to that views page as the
-    # clients won't know anything about the row and hence a `row_updated` event makes
-    # no sense for them.
-
-    public_views_where_row_was_deleted = []
-    public_views_where_row_was_updated = []
-    for old_row_view in old_row_public_views:
-        updated_row_view = updated_row_public_views.pop(old_row_view.slug, None)
-        if updated_row_view is None:
-            # The updated row is no longer visible in `old_row_view` hence we should
-            # send that view a deleted event.
-            public_views_where_row_was_deleted.append(old_row_view)
-        else:
-            # The updated row is still visible so here we want a normal updated event.
-            public_views_where_row_was_updated.append(old_row_view)
-    # Any remaining views in the updated_row_public_views dict are views which
-    # previously didn't show the old row, but now show the new row, so we want created.
-    public_views_where_row_was_created = updated_row_public_views.values()
-
-    def _send_created_updated_deleted_row_signals_to_views():
-        _send_row_deleted_event_to_views(
-            serialized_old_row, public_views_where_row_was_deleted
-        )
-        _send_row_created_event_to_views(
-            serialized_updated_row,
-            before=None,
-            public_views=public_views_where_row_was_created,
-        )
-
-        view_page_type = page_registry.get("view")
-        handler = ViewHandler()
-        for public_view in public_views_where_row_was_updated:
-            (
-                visible_fields_only_updated_row,
-                visible_fields_only_old_row,
-            ) = handler.restrict_rows_for_view(
-                public_view, [serialized_updated_row, serialized_old_row]
-            )
-            view_page_type.broadcast(
-                RealtimeRowMessages.row_updated(
-                    table_id=PUBLIC_PLACEHOLDER_ENTITY_ID,
-                    serialized_row_before_update=visible_fields_only_old_row,
-                    serialized_row=visible_fields_only_updated_row,
-                    metadata={},
-                ),
-                slug=public_view.slug,
-            )
-
-    transaction.on_commit(_send_created_updated_deleted_row_signals_to_views)
-
-
 @receiver(row_signals.rows_updated)
 def public_rows_updated(
     sender, rows, user, table, model, before_return, updated_field_ids, **kwargs
@@ -332,10 +154,10 @@ def public_rows_updated(
     }
 
     # When a row is updated from the point of view of a public view it might not always
-    # result in a `row_updated` event. For example if the row was previously not visible
+    # result in a `rows_updated` event. For example if a row was previously not visible
     # in the public view due to its filters, but the row update makes it now match
-    # the filters we want to send a `row_created` event to that views page as the
-    # clients won't know anything about the row and hence a `row_updated` event makes
+    # the filters we want to send a `rows_created` event to that views page as the
+    # clients won't know anything about the row and hence a `rows_updated` event makes
     # no sense for them.
     public_views_where_rows_were_created: List[PublicViewRows] = []
     public_views_where_rows_were_updated: List[PublicViewRows] = []
diff --git a/backend/src/baserow/contrib/database/ws/rows/signals.py b/backend/src/baserow/contrib/database/ws/rows/signals.py
index c24caf66d..8a3bf6bcd 100644
--- a/backend/src/baserow/contrib/database/ws/rows/signals.py
+++ b/backend/src/baserow/contrib/database/ws/rows/signals.py
@@ -13,27 +13,6 @@ from baserow.contrib.database.table.models import GeneratedTableModel
 from baserow.ws.registries import page_registry
 
 
-@receiver(row_signals.row_created)
-def row_created(sender, row, before, user, table, model, **kwargs):
-    table_page_type = page_registry.get("table")
-    transaction.on_commit(
-        lambda: table_page_type.broadcast(
-            RealtimeRowMessages.row_created(
-                table_id=table.id,
-                serialized_row=get_row_serializer_class(
-                    model, RowSerializer, is_response=True
-                )(row).data,
-                metadata=row_metadata_registry.generate_and_merge_metadata_for_row(
-                    table, row.id
-                ),
-                before=before,
-            ),
-            getattr(user, "web_socket_id", None),
-            table_id=table.id,
-        )
-    )
-
-
 @receiver(row_signals.rows_created)
 def rows_created(sender, rows, before, user, table, model, **kwargs):
     table_page_type = page_registry.get("table")
@@ -55,14 +34,6 @@ def rows_created(sender, rows, before, user, table, model, **kwargs):
     )
 
 
-@receiver(row_signals.before_row_update)
-def before_row_update(sender, row, user, table, model, updated_field_ids, **kwargs):
-    # Generate a serialized version of the row before it is updated. The
-    # `row_updated` receiver needs this serialized version because it can't serialize
-    # the old row after it has been updated.
-    return get_row_serializer_class(model, RowSerializer, is_response=True)(row).data
-
-
 @receiver(row_signals.before_rows_update)
 def before_rows_update(sender, rows, user, table, model, updated_field_ids, **kwargs):
     return get_row_serializer_class(model, RowSerializer, is_response=True)(
@@ -70,29 +41,6 @@ def before_rows_update(sender, rows, user, table, model, updated_field_ids, **kw
     ).data
 
 
-@receiver(row_signals.row_updated)
-def row_updated(
-    sender, row, user, table, model, before_return, updated_field_ids, **kwargs
-):
-    table_page_type = page_registry.get("table")
-    transaction.on_commit(
-        lambda: table_page_type.broadcast(
-            RealtimeRowMessages.row_updated(
-                table_id=table.id,
-                serialized_row_before_update=dict(before_return)[before_row_update],
-                serialized_row=get_row_serializer_class(
-                    model, RowSerializer, is_response=True
-                )(row).data,
-                metadata=row_metadata_registry.generate_and_merge_metadata_for_row(
-                    table, row.id
-                ),
-            ),
-            getattr(user, "web_socket_id", None),
-            table_id=table.id,
-        )
-    )
-
-
 @receiver(row_signals.rows_updated)
 def rows_updated(
     sender, rows, user, table, model, before_return, updated_field_ids, **kwargs
@@ -116,14 +64,6 @@ def rows_updated(
     )
 
 
-@receiver(row_signals.before_row_delete)
-def before_row_delete(sender, row, user, table, model, **kwargs):
-    # Generate a serialized version of the row before it is deleted. The
-    # `row_deleted` receiver needs this serialized version because it can't serialize
-    # the row after is has been deleted.
-    return get_row_serializer_class(model, RowSerializer, is_response=True)(row).data
-
-
 @receiver(row_signals.before_rows_delete)
 def before_rows_delete(sender, rows, user, table, model, **kwargs):
     return get_row_serializer_class(model, RowSerializer, is_response=True)(
@@ -131,20 +71,6 @@ def before_rows_delete(sender, rows, user, table, model, **kwargs):
     ).data
 
 
-@receiver(row_signals.row_deleted)
-def row_deleted(sender, row_id, row, user, table, model, before_return, **kwargs):
-    table_page_type = page_registry.get("table")
-    transaction.on_commit(
-        lambda: table_page_type.broadcast(
-            RealtimeRowMessages.row_deleted(
-                table_id=table.id, serialized_row=dict(before_return)[before_row_delete]
-            ),
-            getattr(user, "web_socket_id", None),
-            table_id=table.id,
-        )
-    )
-
-
 @receiver(row_signals.rows_deleted)
 def rows_deleted(sender, rows, user, table, model, before_return, **kwargs):
     table_page_type = page_registry.get("table")
@@ -166,18 +92,6 @@ class RealtimeRowMessages:
     websocket messages related to rows.
     """
 
-    @staticmethod
-    def row_deleted(table_id: int, serialized_row: Dict[str, Any]) -> Dict[str, Any]:
-        return {
-            "type": "row_deleted",
-            "table_id": table_id,
-            "row_id": serialized_row["id"],
-            # The web-frontend expects a serialized version of the row that is
-            # deleted in order the estimate what position the row had in the view,
-            # or find which kanban column the row was in etc.
-            "row": serialized_row,
-        }
-
     @staticmethod
     def rows_deleted(
         table_id: int, serialized_rows: List[Dict[str, Any]]
@@ -189,21 +103,6 @@ class RealtimeRowMessages:
             "rows": serialized_rows,
         }
 
-    @staticmethod
-    def row_created(
-        table_id: int,
-        serialized_row: Dict[str, Any],
-        metadata: Dict[str, Any],
-        before: Optional[GeneratedTableModel],
-    ) -> Dict[str, Any]:
-        return {
-            "type": "row_created",
-            "table_id": table_id,
-            "row": serialized_row,
-            "metadata": metadata,
-            "before_row_id": before.id if before else None,
-        }
-
     @staticmethod
     def rows_created(
         table_id: int,
@@ -219,24 +118,6 @@ class RealtimeRowMessages:
             "before_row_id": before.id if before else None,
         }
 
-    @staticmethod
-    def row_updated(
-        table_id: int,
-        serialized_row_before_update: Dict[str, Any],
-        serialized_row: Dict[str, Any],
-        metadata: Dict[str, Any],
-    ) -> Dict[str, Any]:
-        return {
-            "type": "row_updated",
-            "table_id": table_id,
-            # The web-frontend expects a serialized version of the row before it
-            # was updated in order the estimate what position the row had in the
-            # view.
-            "row_before_update": serialized_row_before_update,
-            "row": serialized_row,
-            "metadata": metadata,
-        }
-
     @staticmethod
     def rows_updated(
         table_id: int,
diff --git a/backend/src/baserow/contrib/database/ws/signals.py b/backend/src/baserow/contrib/database/ws/signals.py
index 36b4449cf..9297e2df7 100644
--- a/backend/src/baserow/contrib/database/ws/signals.py
+++ b/backend/src/baserow/contrib/database/ws/signals.py
@@ -3,11 +3,9 @@ from django.conf import settings
 from .table.signals import table_created, table_updated, table_deleted
 from .views.signals import view_created, views_reordered, view_updated, view_deleted
 from .rows.signals import (
-    row_created,
     rows_created,
-    row_updated,
     rows_updated,
-    row_deleted,
+    rows_deleted,
 )
 from .fields.signals import field_created, field_updated, field_deleted
 
@@ -16,9 +14,9 @@ if settings.DISABLE_ANONYMOUS_PUBLIC_VIEW_WS_CONNECTIONS:
 else:
     # noinspection PyUnresolvedReferences
     from .public.rows.signals import (  # noqa: F401
-        public_row_created,
-        public_row_deleted,
-        public_row_updated,
+        public_rows_created,
+        public_rows_deleted,
+        public_rows_updated,
     )
 
     # noinspection PyUnresolvedReferences
@@ -39,9 +37,9 @@ else:
     )
 
     PUBLIC_SIGNALS = [
-        "public_row_created",
-        "public_row_deleted",
-        "public_row_updated",
+        "public_rows_created",
+        "public_rows_deleted",
+        "public_rows_updated",
         "public_view_filter_updated",
         "public_view_filter_deleted",
         "public_view_filter_created",
@@ -61,11 +59,9 @@ __all__ = [
     "view_created",
     "view_updated",
     "view_deleted",
-    "row_created",
     "rows_created",
-    "row_updated",
     "rows_updated",
-    "row_deleted",
+    "rows_deleted",
     "field_created",
     "field_updated",
     "field_deleted",
diff --git a/backend/src/baserow/test_utils/fixtures/webhook.py b/backend/src/baserow/test_utils/fixtures/webhook.py
index f952b8726..d822f8c7d 100644
--- a/backend/src/baserow/test_utils/fixtures/webhook.py
+++ b/backend/src/baserow/test_utils/fixtures/webhook.py
@@ -42,7 +42,7 @@ class TableWebhookFixture:
             kwargs["webhook"] = self.create_table_webhook(user=None)
 
         if "event_type" not in kwargs:
-            kwargs["event_type"] = "row.created"
+            kwargs["event_type"] = "rows.created"
 
         if "called_url" not in kwargs:
             kwargs["called_url"] = self.fake.url()
diff --git a/backend/tests/baserow/contrib/database/api/webhooks/test_webhook_views.py b/backend/tests/baserow/contrib/database/api/webhooks/test_webhook_views.py
index e8b1fd854..bd86319f9 100644
--- a/backend/tests/baserow/contrib/database/api/webhooks/test_webhook_views.py
+++ b/backend/tests/baserow/contrib/database/api/webhooks/test_webhook_views.py
@@ -24,7 +24,7 @@ def test_list_webhooks(api_client, data_fixture):
     )
     call_1 = data_fixture.create_table_webhook_call(webhook=webhook_1)
     webhook_2 = data_fixture.create_table_webhook(
-        table=table, include_all_events=False, events=["row.created"]
+        table=table, include_all_events=False, events=["rows.created"]
     )
     data_fixture.create_table_webhook()
 
@@ -74,7 +74,7 @@ def test_list_webhooks(api_client, data_fixture):
     }
 
     assert response_json[1]["id"] == webhook_2.id
-    assert response_json[1]["events"] == ["row.created"]
+    assert response_json[1]["events"] == ["rows.created"]
 
 
 @pytest.mark.django_db
@@ -138,7 +138,7 @@ def test_create_webhooks(api_client, data_fixture):
             "url": "https://mydomain.com/endpoint",
             "name": "My Webhook 2",
             "include_all_events": False,
-            "events": ["row.created"],
+            "events": ["rows.created"],
             "headers": {"Baserow-add-1": "Value 1"},
             "request_method": "PATCH",
             "use_user_field_names": False,
@@ -154,7 +154,7 @@ def test_create_webhooks(api_client, data_fixture):
     assert response_json["name"] == "My Webhook 2"
     assert response_json["include_all_events"] is False
     assert response_json["failed_triggers"] == 0
-    assert response_json["events"] == ["row.created"]
+    assert response_json["events"] == ["rows.created"]
     assert response_json["headers"] == {"Baserow-add-1": "Value 1"}
     assert response_json["calls"] == []
     assert TableWebhook.objects.all().count() == 2
@@ -164,7 +164,7 @@ def test_create_webhooks(api_client, data_fixture):
         {
             "url": "https://mydomain.com/endpoint",
             "name": "My Webhook 2",
-            "events": ["row.created"],
+            "events": ["rows.created"],
             "headers": {"Test:": "Value 1"},
             "request_method": "PATCH",
         },
@@ -197,7 +197,7 @@ def test_create_webhooks(api_client, data_fixture):
         {
             "url": "https://mydomain.com:8a/endpoint",
             "name": "My Webhook 2",
-            "events": ["row.created"],
+            "events": ["rows.created"],
             "request_method": "PATCH",
         },
         format="json",
@@ -214,7 +214,7 @@ def test_create_webhooks(api_client, data_fixture):
         {
             "url": "https://md.com/" + (2001 - len("https://md.com/")) * "a",
             "name": "My Webhook 2",
-            "events": ["row.created"],
+            "events": ["rows.created"],
             "request_method": "PATCH",
         },
         format="json",
@@ -318,7 +318,7 @@ def test_update_webhook(api_client, data_fixture):
             "url": "https://mydomain.com/endpoint",
             "name": "My Webhook 2",
             "include_all_events": False,
-            "events": ["row.created"],
+            "events": ["rows.created"],
             "headers": {"Baserow-add-1": "Value 1"},
             "request_method": "PATCH",
             "use_user_field_names": False,
@@ -335,7 +335,7 @@ def test_update_webhook(api_client, data_fixture):
     assert response_json["name"] == "My Webhook 2"
     assert response_json["include_all_events"] is False
     assert response_json["failed_triggers"] == 0
-    assert response_json["events"] == ["row.created"]
+    assert response_json["events"] == ["rows.created"]
     assert response_json["headers"] == {"Baserow-add-1": "Value 1"}
     assert response_json["calls"] == []
 
@@ -401,7 +401,7 @@ def test_trigger_test_call(api_client, data_fixture):
         reverse("api:database:webhooks:test", kwargs={"table_id": 0}),
         {
             "url": "http://baserow.io",
-            "event_type": "row.created",
+            "event_type": "rows.created",
         },
         format="json",
         HTTP_AUTHORIZATION=f"JWT {jwt_token}",
@@ -413,7 +413,7 @@ def test_trigger_test_call(api_client, data_fixture):
         reverse("api:database:webhooks:test", kwargs={"table_id": table.id}),
         {
             "url": "http://baserow.io",
-            "event_type": "row.created",
+            "event_type": "rows.created",
         },
         format="json",
         HTTP_AUTHORIZATION=f"JWT {jwt_token_2}",
@@ -425,7 +425,7 @@ def test_trigger_test_call(api_client, data_fixture):
         reverse("api:database:webhooks:test", kwargs={"table_id": table.id}),
         {
             "url": "http://baserow.io",
-            "event_type": "row.created",
+            "event_type": "rows.created",
         },
         format="json",
         HTTP_AUTHORIZATION=f"JWT {jwt_token}",
@@ -441,7 +441,7 @@ def test_trigger_test_call(api_client, data_fixture):
         reverse("api:database:webhooks:test", kwargs={"table_id": table.id}),
         {
             "url": "http://baserow.io/invalid",
-            "event_type": "row.created",
+            "event_type": "rows.created",
         },
         format="json",
         HTTP_AUTHORIZATION=f"JWT {jwt_token}",
diff --git a/backend/tests/baserow/contrib/database/rows/test_row_webhook_event_types.py b/backend/tests/baserow/contrib/database/rows/test_row_webhook_event_types.py
index 87d229b21..57886b205 100644
--- a/backend/tests/baserow/contrib/database/rows/test_row_webhook_event_types.py
+++ b/backend/tests/baserow/contrib/database/rows/test_row_webhook_event_types.py
@@ -3,10 +3,11 @@ import pytest
 from baserow.contrib.database.webhooks.registries import webhook_event_type_registry
 from baserow.contrib.database.fields.handler import FieldHandler
 from baserow.contrib.database.rows.handler import RowHandler
+from baserow.contrib.database.ws.rows.signals import before_rows_update
 
 
 @pytest.mark.django_db()
-def test_row_created_event_type(data_fixture):
+def test_rows_created_event_type(data_fixture):
     user = data_fixture.create_user()
     table = data_fixture.create_database_table(user=user)
     field = data_fixture.create_text_field(table=table, primary=True, name="Test 1")
@@ -19,41 +20,43 @@ def test_row_created_event_type(data_fixture):
         url="http://localhost",
         use_user_field_names=False,
     )
-    payload = webhook_event_type_registry.get("row.created").get_payload(
-        event_id="1", webhook=webhook, model=model, table=table, row=row
+    payload = webhook_event_type_registry.get("rows.created").get_payload(
+        event_id="1", webhook=webhook, model=model, table=table, rows=[row]
     )
     assert payload == {
         "table_id": table.id,
         "event_id": "1",
-        "event_type": "row.created",
-        "row_id": row.id,
-        "values": {
-            "id": 1,
-            "order": "1.00000000000000000000",
-            f"field_{field.id}": None,
-        },
+        "event_type": "rows.created",
+        "items": [
+            {
+                "id": 1,
+                "order": "1.00000000000000000000",
+                f"field_{field.id}": None,
+            }
+        ],
     }
 
     webhook.use_user_field_names = True
     webhook.save()
-    payload = webhook_event_type_registry.get("row.created").get_payload(
-        event_id="1", webhook=webhook, model=model, table=table, row=row
+    payload = webhook_event_type_registry.get("rows.created").get_payload(
+        event_id="1", webhook=webhook, model=model, table=table, rows=[row]
     )
     assert payload == {
         "table_id": table.id,
         "event_id": "1",
-        "event_type": "row.created",
-        "row_id": row.id,
-        "values": {
-            "id": 1,
-            "order": "1.00000000000000000000",
-            "Test 1": None,
-        },
+        "event_type": "rows.created",
+        "items": [
+            {
+                "id": 1,
+                "order": "1.00000000000000000000",
+                "Test 1": None,
+            }
+        ],
     }
 
 
 @pytest.mark.django_db()
-def test_row_updated_event_type(data_fixture):
+def test_rows_updated_event_type(data_fixture):
     user = data_fixture.create_user()
     table = data_fixture.create_database_table(user=user)
     table_2 = data_fixture.create_database_table(database=table.database)
@@ -85,9 +88,16 @@ def test_row_updated_event_type(data_fixture):
     row = model.objects.create(**{f"field_{text_field.id}": "Old Test value"})
     getattr(row, f"field_{link_row_field.id}").add(i1.id)
 
-    before_return = webhook_event_type_registry.get(
-        "row.updated"
-    ).get_test_call_before_return(table, row, model)
+    before_return = {
+        before_rows_update: before_rows_update(
+            rows=[row],
+            model=model,
+            table=table,
+            sender=None,
+            user=None,
+            updated_field_ids=None,
+        )
+    }
 
     row = RowHandler().update_row_by_id(
         user=user,
@@ -103,65 +113,71 @@ def test_row_updated_event_type(data_fixture):
         url="http://localhost",
         use_user_field_names=False,
     )
-    payload = webhook_event_type_registry.get("row.updated").get_payload(
+    payload = webhook_event_type_registry.get("rows.updated").get_payload(
         event_id="1",
         webhook=webhook,
         model=model,
         table=table,
-        row=row,
+        rows=[row],
         before_return=before_return,
     )
     assert payload == {
         "table_id": table.id,
         "event_id": "1",
-        "event_type": "row.updated",
-        "row_id": row.id,
-        "values": {
-            "id": 1,
-            "order": "1.00000000000000000000",
-            f"field_{text_field.id}": "New Test value",
-            f"field_{link_row_field.id}": [{"id": 1, "value": "Lookup 1"}],
-        },
-        "old_values": {
-            "id": 1,
-            "order": "1.00000000000000000000",
-            f"field_{text_field.id}": "Old Test value",
-            f"field_{link_row_field.id}": [{"id": 1, "value": "Lookup 1"}],
-        },
+        "event_type": "rows.updated",
+        "items": [
+            {
+                "id": 1,
+                "order": "1.00000000000000000000",
+                f"field_{text_field.id}": "New Test value",
+                f"field_{link_row_field.id}": [{"id": 1, "value": "Lookup 1"}],
+            }
+        ],
+        "old_items": [
+            {
+                "id": 1,
+                "order": "1.00000000000000000000",
+                f"field_{text_field.id}": "Old Test value",
+                f"field_{link_row_field.id}": [{"id": 1, "value": "Lookup 1"}],
+            }
+        ],
     }
 
     webhook.use_user_field_names = True
     webhook.save()
-    payload = webhook_event_type_registry.get("row.updated").get_payload(
+    payload = webhook_event_type_registry.get("rows.updated").get_payload(
         event_id="1",
         webhook=webhook,
         model=model,
         table=table,
-        row=row,
+        rows=[row],
         before_return=before_return,
     )
     assert payload == {
         "table_id": table.id,
         "event_id": "1",
-        "event_type": "row.updated",
-        "row_id": row.id,
-        "values": {
-            "id": 1,
-            "order": "1.00000000000000000000",
-            f"{text_field.name}": "New Test value",
-            f"{link_row_field.name}": [{"id": 1, "value": "Lookup 1"}],
-        },
-        "old_values": {
-            "id": 1,
-            "order": "1.00000000000000000000",
-            f"{text_field.name}": "Old Test value",
-            f"{link_row_field.name}": [{"id": 1, "value": "Lookup 1"}],
-        },
+        "event_type": "rows.updated",
+        "items": [
+            {
+                "id": 1,
+                "order": "1.00000000000000000000",
+                f"{text_field.name}": "New Test value",
+                f"{link_row_field.name}": [{"id": 1, "value": "Lookup 1"}],
+            }
+        ],
+        "old_items": [
+            {
+                "id": 1,
+                "order": "1.00000000000000000000",
+                f"{text_field.name}": "Old Test value",
+                f"{link_row_field.name}": [{"id": 1, "value": "Lookup 1"}],
+            }
+        ],
     }
 
 
 @pytest.mark.django_db()
-def test_row_deleted_event_type(data_fixture):
+def test_rows_deleted_event_type(data_fixture):
     user = data_fixture.create_user()
     table = data_fixture.create_database_table(user=user)
     data_fixture.create_text_field(table=table, primary=True, name="Test 1")
@@ -174,17 +190,17 @@ def test_row_deleted_event_type(data_fixture):
         url="http://localhost",
         use_user_field_names=False,
     )
-    payload = webhook_event_type_registry.get("row.deleted").get_payload(
+    payload = webhook_event_type_registry.get("rows.deleted").get_payload(
         event_id="1",
         webhook=webhook,
         model=model,
         table=table,
-        row=row,
+        rows=[row],
     )
 
     assert payload == {
         "table_id": table.id,
         "event_id": "1",
-        "event_type": "row.deleted",
-        "row_id": row.id,
+        "event_type": "rows.deleted",
+        "row_ids": [row.id],
     }
diff --git a/backend/tests/baserow/contrib/database/rows/test_rows_handler.py b/backend/tests/baserow/contrib/database/rows/test_rows_handler.py
index 26903818d..0aee3e565 100644
--- a/backend/tests/baserow/contrib/database/rows/test_rows_handler.py
+++ b/backend/tests/baserow/contrib/database/rows/test_rows_handler.py
@@ -106,7 +106,7 @@ def test_extract_manytomany_values(data_fixture):
 
 
 @pytest.mark.django_db
-@patch("baserow.contrib.database.rows.signals.row_created.send")
+@patch("baserow.contrib.database.rows.signals.rows_created.send")
 def test_create_row(send_mock, data_fixture):
     user = data_fixture.create_user()
     user_2 = data_fixture.create_user()
@@ -152,7 +152,7 @@ def test_create_row(send_mock, data_fixture):
     assert row_1.order == Decimal("1.00000000000000000000")
 
     send_mock.assert_called_once()
-    assert send_mock.call_args[1]["row"].id == row_1.id
+    assert send_mock.call_args[1]["rows"][0].id == row_1.id
     assert send_mock.call_args[1]["user"].id == user.id
     assert send_mock.call_args[1]["table"].id == table.id
     assert send_mock.call_args[1]["before"] is None
@@ -287,7 +287,7 @@ def test_get_row(data_fixture):
 
 
 @pytest.mark.django_db
-@patch("baserow.contrib.database.rows.signals.row_updated.send")
+@patch("baserow.contrib.database.rows.signals.rows_updated.send")
 def test_update_row(send_mock, data_fixture):
     user = data_fixture.create_user()
     user_2 = data_fixture.create_user()
@@ -320,7 +320,7 @@ def test_update_row(send_mock, data_fixture):
         )
 
     with patch(
-        "baserow.contrib.database.rows.signals.before_row_update.send"
+        "baserow.contrib.database.rows.signals.before_rows_update.send"
     ) as before_send_mock:
         handler.update_row_by_id(
             user=user,
@@ -339,13 +339,13 @@ def test_update_row(send_mock, data_fixture):
     assert getattr(row, f"field_{price_field.id}") == Decimal("59999.99")
 
     before_send_mock.assert_called_once()
-    assert before_send_mock.call_args[1]["row"].id == row.id
+    assert before_send_mock.call_args[1]["rows"][0].id == row.id
     assert before_send_mock.call_args[1]["user"].id == user.id
     assert before_send_mock.call_args[1]["table"].id == table.id
     assert before_send_mock.call_args[1]["model"]._generated_table_model
 
     send_mock.assert_called_once()
-    assert send_mock.call_args[1]["row"].id == row.id
+    assert send_mock.call_args[1]["rows"][0].id == row.id
     assert send_mock.call_args[1]["user"].id == user.id
     assert send_mock.call_args[1]["table"].id == table.id
     assert send_mock.call_args[1]["model"]._generated_table_model
@@ -387,8 +387,8 @@ def test_update_rows_created_on_and_last_modified(data_fixture):
 
 
 @pytest.mark.django_db
-@patch("baserow.contrib.database.rows.signals.row_updated.send")
-@patch("baserow.contrib.database.rows.signals.before_row_update.send")
+@patch("baserow.contrib.database.rows.signals.rows_updated.send")
+@patch("baserow.contrib.database.rows.signals.before_rows_update.send")
 def test_move_row(before_send_mock, send_mock, data_fixture):
     user = data_fixture.create_user()
     user_2 = data_fixture.create_user()
@@ -414,13 +414,13 @@ def test_move_row(before_send_mock, send_mock, data_fixture):
     assert row_3.order == Decimal("3.00000000000000000000")
 
     before_send_mock.assert_called_once()
-    assert before_send_mock.call_args[1]["row"].id == row_1.id
+    assert before_send_mock.call_args[1]["rows"][0].id == row_1.id
     assert before_send_mock.call_args[1]["user"].id == user.id
     assert before_send_mock.call_args[1]["table"].id == table.id
     assert before_send_mock.call_args[1]["model"]._generated_table_model
 
     send_mock.assert_called_once()
-    assert send_mock.call_args[1]["row"].id == row_1.id
+    assert send_mock.call_args[1]["rows"][0].id == row_1.id
     assert send_mock.call_args[1]["user"].id == user.id
     assert send_mock.call_args[1]["table"].id == table.id
     assert send_mock.call_args[1]["model"]._generated_table_model
@@ -441,8 +441,8 @@ def test_move_row(before_send_mock, send_mock, data_fixture):
 
 
 @pytest.mark.django_db
-@patch("baserow.contrib.database.rows.signals.row_deleted.send")
-@patch("baserow.contrib.database.rows.signals.before_row_delete.send")
+@patch("baserow.contrib.database.rows.signals.rows_deleted.send")
+@patch("baserow.contrib.database.rows.signals.before_rows_delete.send")
 def test_delete_row(before_send_mock, send_mock, data_fixture):
     user = data_fixture.create_user()
     user_2 = data_fixture.create_user()
@@ -468,14 +468,13 @@ def test_delete_row(before_send_mock, send_mock, data_fixture):
     assert row.trashed
 
     before_send_mock.assert_called_once()
-    assert before_send_mock.call_args[1]["row"]
+    assert before_send_mock.call_args[1]["rows"]
     assert before_send_mock.call_args[1]["user"].id == user.id
     assert before_send_mock.call_args[1]["table"].id == table.id
     assert before_send_mock.call_args[1]["model"]._generated_table_model
 
     send_mock.assert_called_once()
-    assert send_mock.call_args[1]["row_id"] == row_id
-    assert send_mock.call_args[1]["row"]
+    assert send_mock.call_args[1]["rows"][0].id == row_id
     assert send_mock.call_args[1]["user"].id == user.id
     assert send_mock.call_args[1]["table"].id == table.id
     assert send_mock.call_args[1]["model"]._generated_table_model
@@ -483,7 +482,7 @@ def test_delete_row(before_send_mock, send_mock, data_fixture):
 
 
 @pytest.mark.django_db
-@patch("baserow.contrib.database.rows.signals.row_created.send")
+@patch("baserow.contrib.database.rows.signals.rows_created.send")
 def test_restore_row(send_mock, data_fixture):
     user = data_fixture.create_user()
     table = data_fixture.create_database_table(name="Car", user=user)
@@ -516,7 +515,7 @@ def test_restore_row(send_mock, data_fixture):
     TrashHandler.restore_item(user, "row", row_1.id, parent_trash_item_id=table.id)
 
     assert len(send_mock.call_args) == 2
-    assert send_mock.call_args[1]["row"].id == row_1.id
+    assert send_mock.call_args[1]["rows"][0].id == row_1.id
     assert send_mock.call_args[1]["user"] is None
     assert send_mock.call_args[1]["table"].id == table.id
     assert send_mock.call_args[1]["before"] is None
diff --git a/backend/tests/baserow/contrib/database/trash/test_database_trash_types.py b/backend/tests/baserow/contrib/database/trash/test_database_trash_types.py
index 6f31fa25e..f43123938 100644
--- a/backend/tests/baserow/contrib/database/trash/test_database_trash_types.py
+++ b/backend/tests/baserow/contrib/database/trash/test_database_trash_types.py
@@ -475,16 +475,18 @@ def test_trash_and_restore_rows_in_batch(send_mock, data_fixture):
     customers_primary_field = field_handler.create_field(
         user=user, table=customers_table, type_name="text", name="Name", primary=True
     )
-    row1 = row_handler.create_row(
-        user=user,
-        table=customers_table,
-        values={f"field_{customers_primary_field.id}": "Row A"},
-    )
-    row2 = row_handler.create_row(
-        user=user,
-        table=customers_table,
-        values={f"field_{customers_primary_field.id}": ""},
-    )
+
+    with patch("baserow.contrib.database.rows.signals.rows_created.send"):
+        row1 = row_handler.create_row(
+            user=user,
+            table=customers_table,
+            values={f"field_{customers_primary_field.id}": "Row A"},
+        )
+        row2 = row_handler.create_row(
+            user=user,
+            table=customers_table,
+            values={f"field_{customers_primary_field.id}": ""},
+        )
 
     trashed_rows = TrashedRows.objects.create(
         table=customers_table, row_ids=[row1.id, row2.id]
diff --git a/backend/tests/baserow/contrib/database/view/test_view_handler.py b/backend/tests/baserow/contrib/database/view/test_view_handler.py
index 65713d1c5..66ba3281e 100644
--- a/backend/tests/baserow/contrib/database/view/test_view_handler.py
+++ b/backend/tests/baserow/contrib/database/view/test_view_handler.py
@@ -1419,7 +1419,7 @@ def test_get_public_view_by_slug(data_fixture):
 
 
 @pytest.mark.django_db
-@patch("baserow.contrib.database.rows.signals.row_created.send")
+@patch("baserow.contrib.database.rows.signals.rows_created.send")
 def test_submit_form_view(send_mock, data_fixture):
     table = data_fixture.create_database_table()
     form = data_fixture.create_form_view(table=table)
@@ -1451,7 +1451,7 @@ def test_submit_form_view(send_mock, data_fixture):
     )
 
     send_mock.assert_called_once()
-    assert send_mock.call_args[1]["row"].id == instance.id
+    assert send_mock.call_args[1]["rows"][0].id == instance.id
     assert send_mock.call_args[1]["user"] is None
     assert send_mock.call_args[1]["table"].id == table.id
     assert send_mock.call_args[1]["before"] is None
diff --git a/backend/tests/baserow/contrib/database/webhooks/test_webhook_handler.py b/backend/tests/baserow/contrib/database/webhooks/test_webhook_handler.py
index e635c5edb..f3dded6e4 100644
--- a/backend/tests/baserow/contrib/database/webhooks/test_webhook_handler.py
+++ b/backend/tests/baserow/contrib/database/webhooks/test_webhook_handler.py
@@ -23,15 +23,15 @@ def test_find_webhooks_to_call(data_fixture):
         table=table_1, include_all_events=True, active=True
     )
     webhook_2 = data_fixture.create_table_webhook(
-        table=table_1, include_all_events=False, events=["row.created"], active=True
+        table=table_1, include_all_events=False, events=["rows.created"], active=True
     )
     data_fixture.create_table_webhook(
-        table=table_1, include_all_events=False, events=["row.updated"], active=False
+        table=table_1, include_all_events=False, events=["rows.updated"], active=False
     )
     webhook_4 = data_fixture.create_table_webhook(
         table=table_1,
         include_all_events=False,
-        events=["row.updated", "row.deleted"],
+        events=["rows.updated", "rows.deleted"],
         active=True,
     )
     webhook_5 = data_fixture.create_table_webhook(
@@ -42,42 +42,42 @@ def test_find_webhooks_to_call(data_fixture):
     webhook_6 = data_fixture.create_table_webhook(
         table=table_2,
         include_all_events=False,
-        events=["row.updated"],
+        events=["rows.updated"],
         active=True,
     )
 
     handler = WebhookHandler()
 
-    webhooks = handler.find_webhooks_to_call(table_1.id, "row.created")
+    webhooks = handler.find_webhooks_to_call(table_1.id, "rows.created")
     webhook_ids = [webhook.id for webhook in webhooks]
     assert len(webhook_ids) == 2
     assert webhook_1.id in webhook_ids
     assert webhook_2.id in webhook_ids
 
-    webhooks = handler.find_webhooks_to_call(table_1.id, "row.updated")
+    webhooks = handler.find_webhooks_to_call(table_1.id, "rows.updated")
     webhook_ids = [webhook.id for webhook in webhooks]
     assert len(webhook_ids) == 2
     assert webhook_1.id in webhook_ids
     assert webhook_4.id in webhook_ids
 
-    webhooks = handler.find_webhooks_to_call(table_1.id, "row.deleted")
+    webhooks = handler.find_webhooks_to_call(table_1.id, "rows.deleted")
     webhook_ids = [webhook.id for webhook in webhooks]
     assert len(webhook_ids) == 2
     assert webhook_1.id in webhook_ids
     assert webhook_4.id in webhook_ids
 
-    webhooks = handler.find_webhooks_to_call(table_2.id, "row.created")
+    webhooks = handler.find_webhooks_to_call(table_2.id, "rows.created")
     webhook_ids = [webhook.id for webhook in webhooks]
     assert len(webhook_ids) == 1
     assert webhook_5.id in webhook_ids
 
-    webhooks = handler.find_webhooks_to_call(table_2.id, "row.updated")
+    webhooks = handler.find_webhooks_to_call(table_2.id, "rows.updated")
     webhook_ids = [webhook.id for webhook in webhooks]
     assert len(webhook_ids) == 2
     assert webhook_5.id in webhook_ids
     assert webhook_6.id in webhook_ids
 
-    webhooks = handler.find_webhooks_to_call(table_2.id, "row.deleted")
+    webhooks = handler.find_webhooks_to_call(table_2.id, "rows.deleted")
     webhook_ids = [webhook.id for webhook in webhooks]
     assert len(webhook_ids) == 1
     assert webhook_5.id in webhook_ids
@@ -118,7 +118,7 @@ def test_get_all_table_webhooks(data_fixture, django_assert_num_queries):
 
     webhook_1 = data_fixture.create_table_webhook(
         table=table,
-        events=["row.created", "row.updated"],
+        events=["rows.created", "rows.updated"],
         headers={"Baserow-test-2": "Value 2"},
         include_all_events=False,
     )
@@ -184,7 +184,7 @@ def test_create_webhook(data_fixture):
 
     # if "include_all_events" is True and we pass in events that are not empty
     # the handler will not create the entry in the events table.
-    events = ["row.created"]
+    events = ["rows.created"]
     webhook = webhook_handler.create_table_webhook(
         user=user, table=table, events=events, headers={}, **dict(webhook_data)
     )
@@ -202,7 +202,7 @@ def test_create_webhook(data_fixture):
     assert webhook.include_all_events is False
     webhook_events = webhook.events.all()
     assert len(webhook_events) == 1
-    assert webhook_events[0].event_type == "row.created"
+    assert webhook_events[0].event_type == "rows.created"
     webhook_headers = webhook.headers.all()
     assert len(webhook_headers) == 2
     assert webhook_headers[0].name == "Baserow-test-1"
@@ -232,7 +232,7 @@ def test_update_webhook(data_fixture):
     table = data_fixture.create_database_table(user=user)
     webhook = data_fixture.create_table_webhook(
         table=table,
-        events=["row.created"],
+        events=["rows.created"],
         headers={"Baserow-test-1": "Value 1", "Baserow-test-2": "Value 2"},
     )
 
@@ -262,24 +262,24 @@ def test_update_webhook(data_fixture):
 
     events_before = list(webhook.events.all())
     webhook = handler.update_table_webhook(
-        user=user, webhook=webhook, events=["row.created", "row.updated"]
+        user=user, webhook=webhook, events=["rows.created", "rows.updated"]
     )
     events = webhook.events.all().order_by("id")
     assert len(events) == 2
     assert events[0].id == events_before[0].id
-    assert events[0].event_type == events_before[0].event_type == "row.created"
-    assert events[1].event_type == "row.updated"
+    assert events[0].event_type == events_before[0].event_type == "rows.created"
+    assert events[1].event_type == "rows.updated"
 
     webhook = handler.update_table_webhook(
-        user=user, webhook=webhook, events=["row.updated"]
+        user=user, webhook=webhook, events=["rows.updated"]
     )
     events_2 = webhook.events.all().order_by("id")
     assert len(events_2) == 1
     assert events_2[0].id == events[1].id
-    assert events_2[0].event_type == events[1].event_type == "row.updated"
+    assert events_2[0].event_type == events[1].event_type == "rows.updated"
 
     webhook = handler.update_table_webhook(
-        user=user, webhook=webhook, include_all_events=True, events=["row.created"]
+        user=user, webhook=webhook, include_all_events=True, events=["rows.created"]
     )
     assert webhook.events.all().count() == 0
 
@@ -305,7 +305,7 @@ def test_delete_webhook(data_fixture):
     webhook = data_fixture.create_table_webhook(
         user=user,
         headers={"A": "B"},
-        events=["row.created"],
+        events=["rows.created"],
         include_all_events=False,
     )
     data_fixture.create_table_webhook()
@@ -330,23 +330,24 @@ def test_trigger_test_call(data_fixture):
     handler = WebhookHandler()
 
     with pytest.raises(UserNotInGroup):
-        handler.trigger_test_call(user=user_2, table=table, event_type="row.created")
+        handler.trigger_test_call(user=user_2, table=table, event_type="rows.created")
 
     responses.add(responses.POST, "http://localhost", json={}, status=200)
 
     request, response = handler.trigger_test_call(
         user=user,
         table=table,
-        event_type="row.created",
+        event_type="rows.created",
         headers={"Baserow-add-1": "Value 1"},
         request_method="POST",
         url="http://localhost",
         use_user_field_names=False,
     )
     assert response.ok
-    assert request.headers["Baserow-add-1"] == "Value 1"
-    assert request.headers["Content-type"] == "application/json"
-    assert request.headers["X-Baserow-Event"] == "row.created"
+
+    assert response.request.headers["Baserow-add-1"] == "Value 1"
+    assert response.request.headers["Content-type"] == "application/json"
+    assert response.request.headers["X-Baserow-Event"] == "rows.created"
     assert "X-Baserow-Delivery" in request.headers
 
     assert request.method == "POST"
@@ -354,13 +355,14 @@ def test_trigger_test_call(data_fixture):
 
     request_body = json.loads(request.body)
     assert request_body["table_id"] == table.id
-    assert request_body["event_type"] == "row.created"
-    assert request_body["row_id"] == 0
-    assert request_body["values"] == {
-        "id": 0,
-        f"field_{field.id}": None,
-        "order": "0.00000000000000000000",
-    }
+    assert request_body["event_type"] == "rows.created"
+    assert request_body["items"] == [
+        {
+            "id": 0,
+            f"field_{field.id}": None,
+            "order": "0.00000000000000000000",
+        }
+    ]
 
 
 @pytest.mark.django_db
diff --git a/backend/tests/baserow/contrib/database/webhooks/test_webhook_registries.py b/backend/tests/baserow/contrib/database/webhooks/test_webhook_registries.py
index 8cb85c481..6c5b37a7a 100644
--- a/backend/tests/baserow/contrib/database/webhooks/test_webhook_registries.py
+++ b/backend/tests/baserow/contrib/database/webhooks/test_webhook_registries.py
@@ -16,26 +16,26 @@ def test_signal_listener(mock_call_webhook, data_fixture):
         table=table,
         url="http://localhost/",
         include_all_events=False,
-        events=["row.created"],
+        events=["rows.created"],
         headers={"Baserow-header-1": "Value 1"},
     )
 
     RowHandler().create_row(user=user, table=table, values={})
+
     mock_call_webhook.delay.assert_called_once()
     args, kwargs = mock_call_webhook.delay.call_args
     assert kwargs["webhook_id"] == webhook.id
     assert isinstance(kwargs["event_id"], uuid.UUID)
-    assert kwargs["event_type"] == "row.created"
+    assert kwargs["event_type"] == "rows.created"
     assert kwargs["headers"]["Baserow-header-1"] == "Value 1"
     assert kwargs["headers"]["Content-type"] == "application/json"
-    assert kwargs["headers"]["X-Baserow-Event"] == "row.created"
+    assert kwargs["headers"]["X-Baserow-Event"] == "rows.created"
     assert len(kwargs["headers"]["X-Baserow-Delivery"]) > 1
     assert kwargs["method"] == "POST"
     assert kwargs["url"] == "http://localhost/"
     assert kwargs["payload"] == {
         "table_id": table.id,
         "event_id": kwargs["payload"]["event_id"],
-        "event_type": "row.created",
-        "row_id": 1,
-        "values": {"id": 1, "order": "1.00000000000000000000"},
+        "event_type": "rows.created",
+        "items": [{"id": 1, "order": "1.00000000000000000000"}],
     }
diff --git a/backend/tests/baserow/contrib/database/webhooks/test_webhook_tasks.py b/backend/tests/baserow/contrib/database/webhooks/test_webhook_tasks.py
index 59af0bf1f..28544bead 100644
--- a/backend/tests/baserow/contrib/database/webhooks/test_webhook_tasks.py
+++ b/backend/tests/baserow/contrib/database/webhooks/test_webhook_tasks.py
@@ -20,11 +20,11 @@ def test_call_webhook(data_fixture):
     call_webhook.run(
         webhook_id=0,
         event_id="00000000-0000-0000-0000-000000000000",
-        event_type="row.created",
+        event_type="rows.created",
         method="POST",
         url="http://localhost/",
         headers={"Baserow-header-1": "Value 1"},
-        payload={"type": "row.created"},
+        payload={"type": "rows.created"},
     )
     assert TableWebhookCall.objects.all().count() == 0
 
@@ -32,11 +32,11 @@ def test_call_webhook(data_fixture):
         call_webhook.run(
             webhook_id=webhook.id,
             event_id="00000000-0000-0000-0000-000000000000",
-            event_type="row.created",
+            event_type="rows.created",
             method="POST",
             url="http://localhost/",
             headers={"Baserow-header-1": "Value 1"},
-            payload={"type": "row.created"},
+            payload={"type": "rows.created"},
         )
         transaction.commit()
 
@@ -44,7 +44,7 @@ def test_call_webhook(data_fixture):
     created_call = TableWebhookCall.objects.all().first()
     called_time_1 = created_call.called_time
     assert created_call.webhook_id == webhook.id
-    assert created_call.event_type == "row.created"
+    assert created_call.event_type == "rows.created"
     assert created_call.called_time
     assert created_call.called_url == "http://localhost/"
     assert "POST http://localhost/" in created_call.request
@@ -56,11 +56,11 @@ def test_call_webhook(data_fixture):
     call_webhook.run(
         webhook_id=webhook.id,
         event_id="00000000-0000-0000-0000-000000000000",
-        event_type="row.created",
+        event_type="rows.created",
         method="POST",
         url="http://localhost/",
         headers={"Baserow-header-1": "Value 1"},
-        payload={"type": "row.created"},
+        payload={"type": "rows.created"},
     )
 
     webhook.refresh_from_db()
@@ -74,11 +74,11 @@ def test_call_webhook(data_fixture):
     call_webhook.run(
         webhook_id=webhook.id,
         event_id="00000000-0000-0000-0000-000000000001",
-        event_type="row.created",
+        event_type="rows.created",
         method="POST",
         url="http://localhost/",
         headers={"Baserow-header-1": "Value 1"},
-        payload={"type": "row.created"},
+        payload={"type": "rows.created"},
     )
 
     webhook.refresh_from_db()
@@ -92,17 +92,17 @@ def test_call_webhook(data_fixture):
     call_webhook(
         webhook_id=webhook.id,
         event_id="00000000-0000-0000-0000-000000000002",
-        event_type="row.created",
+        event_type="rows.created",
         method="POST",
         url="http://localhost/",
         headers={"Baserow-header-1": "Value 1"},
-        payload={"type": "row.created"},
+        payload={"type": "rows.created"},
     )
 
     assert TableWebhookCall.objects.all().count() == 3
     created_call = TableWebhookCall.objects.all().first()
     assert created_call.webhook_id == webhook.id
-    assert created_call.event_type == "row.created"
+    assert created_call.event_type == "rows.created"
     assert created_call.called_time
     assert created_call.called_url == "http://localhost/"
     assert "POST http://localhost/" in created_call.request
@@ -120,17 +120,17 @@ def test_call_webhook(data_fixture):
         call_webhook(
             webhook_id=webhook.id,
             event_id="00000000-0000-0000-0000-000000000003",
-            event_type="row.created",
+            event_type="rows.created",
             method="POST",
             url="http://localhost2/",
             headers={"Baserow-header-1": "Value 1"},
-            payload={"type": "row.created"},
+            payload={"type": "rows.created"},
         )
 
     assert TableWebhookCall.objects.all().count() == 4
     created_call = TableWebhookCall.objects.all().first()
     assert created_call.webhook_id == webhook.id
-    assert created_call.event_type == "row.created"
+    assert created_call.event_type == "rows.created"
     assert created_call.called_time
     assert created_call.called_url == "http://localhost2/"
     assert "POST http://localhost2/" in created_call.request
diff --git a/backend/tests/baserow/contrib/database/ws/public/test_public_ws_rows_signals.py b/backend/tests/baserow/contrib/database/ws/public/test_public_ws_rows_signals.py
index d7cd44ab1..4c65dfcc6 100644
--- a/backend/tests/baserow/contrib/database/ws/public/test_public_ws_rows_signals.py
+++ b/backend/tests/baserow/contrib/database/ws/public/test_public_ws_rows_signals.py
@@ -45,14 +45,16 @@ def test_when_row_created_public_views_receive_restricted_row_created_ws_event(
             call(
                 f"view-{public_view_only_showing_one_field.slug}",
                 {
-                    "type": "row_created",
+                    "type": "rows_created",
                     "table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
-                    "row": {
-                        "id": row.id,
-                        "order": "1.00000000000000000000",
-                        # Only the visible field should be sent
-                        f"field_{visible_field.id}": "Visible",
-                    },
+                    "rows": [
+                        {
+                            "id": row.id,
+                            "order": "1.00000000000000000000",
+                            # Only the visible field should be sent
+                            f"field_{visible_field.id}": "Visible",
+                        }
+                    ],
                     "metadata": {},
                     "before_row_id": None,
                 },
@@ -61,16 +63,18 @@ def test_when_row_created_public_views_receive_restricted_row_created_ws_event(
             call(
                 f"view-{public_view_showing_all_fields.slug}",
                 {
-                    "type": "row_created",
+                    "type": "rows_created",
                     "table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
-                    "row": {
-                        "id": row.id,
-                        "order": "1.00000000000000000000",
-                        f"field_{visible_field.id}": "Visible",
-                        # This field is not hidden for this public view and so should be
-                        # included
-                        f"field_{hidden_field.id}": "Hidden",
-                    },
+                    "rows": [
+                        {
+                            "id": row.id,
+                            "order": "1.00000000000000000000",
+                            f"field_{visible_field.id}": "Visible",
+                            # This field is not hidden for this public view and so
+                            # should be included
+                            f"field_{hidden_field.id}": "Hidden",
+                        }
+                    ],
                     "metadata": {},
                     "before_row_id": None,
                 },
@@ -137,14 +141,16 @@ def test_when_row_created_public_views_receive_row_created_only_when_filters_mat
             call(
                 f"view-{public_view_showing_row.slug}",
                 {
-                    "type": "row_created",
+                    "type": "rows_created",
                     "table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
-                    "row": {
-                        "id": row.id,
-                        "order": "1.00000000000000000000",
-                        # Only the visible field should be sent
-                        f"field_{visible_field.id}": "Visible",
-                    },
+                    "rows": [
+                        {
+                            "id": row.id,
+                            "order": "1.00000000000000000000",
+                            # Only the visible field should be sent
+                            f"field_{visible_field.id}": "Visible",
+                        }
+                    ],
                     "metadata": {},
                     "before_row_id": None,
                 },
@@ -360,32 +366,36 @@ def test_when_row_deleted_public_views_receive_restricted_row_deleted_ws_event(
             call(
                 f"view-{public_view_only_showing_one_field.slug}",
                 {
-                    "type": "row_deleted",
-                    "row_id": row.id,
+                    "type": "rows_deleted",
+                    "row_ids": [row.id],
                     "table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
-                    "row": {
-                        "id": row.id,
-                        "order": "1.00000000000000000000",
-                        # Only the visible field should be sent
-                        f"field_{visible_field.id}": "Visible",
-                    },
+                    "rows": [
+                        {
+                            "id": row.id,
+                            "order": "1.00000000000000000000",
+                            # Only the visible field should be sent
+                            f"field_{visible_field.id}": "Visible",
+                        }
+                    ],
                 },
                 None,
             ),
             call(
                 f"view-{public_view_showing_all_fields.slug}",
                 {
-                    "type": "row_deleted",
-                    "row_id": row.id,
+                    "type": "rows_deleted",
+                    "row_ids": [row.id],
                     "table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
-                    "row": {
-                        "id": row.id,
-                        "order": "1.00000000000000000000",
-                        f"field_{visible_field.id}": "Visible",
-                        # This field is not hidden for this public view
-                        # and so should be included
-                        f"field_{hidden_field.id}": "Hidden",
-                    },
+                    "rows": [
+                        {
+                            "id": row.id,
+                            "order": "1.00000000000000000000",
+                            f"field_{visible_field.id}": "Visible",
+                            # This field is not hidden for this public view
+                            # and so should be included
+                            f"field_{hidden_field.id}": "Hidden",
+                        }
+                    ],
                 },
                 None,
             ),
@@ -450,15 +460,17 @@ def test_when_row_deleted_public_views_receive_row_deleted_only_when_filters_mat
             call(
                 f"view-{public_view_showing_row.slug}",
                 {
-                    "type": "row_deleted",
+                    "type": "rows_deleted",
                     "table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
-                    "row_id": row.id,
-                    "row": {
-                        "id": row.id,
-                        "order": "1.00000000000000000000",
-                        # Only the visible field should be sent
-                        f"field_{visible_field.id}": "Visible",
-                    },
+                    "row_ids": [row.id],
+                    "rows": [
+                        {
+                            "id": row.id,
+                            "order": "1.00000000000000000000",
+                            # Only the visible field should be sent
+                            f"field_{visible_field.id}": "Visible",
+                        }
+                    ],
                 },
                 None,
             ),
@@ -706,14 +718,16 @@ def test_given_row_not_visible_in_public_view_when_updated_to_be_visible_event_s
                 {
                     # The row should appear as a created event as for the public view
                     # it effectively has been created as it didn't exist before.
-                    "type": "row_created",
+                    "type": "rows_created",
                     "table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
-                    "row": {
-                        "id": initially_hidden_row.id,
-                        "order": "1.00000000000000000000",
-                        # Only the visible field should be sent
-                        f"field_{visible_field.id}": "Visible",
-                    },
+                    "rows": [
+                        {
+                            "id": initially_hidden_row.id,
+                            "order": "1.00000000000000000000",
+                            # Only the visible field should be sent
+                            f"field_{visible_field.id}": "Visible",
+                        }
+                    ],
                     "metadata": {},
                     "before_row_id": None,
                 },
@@ -1154,16 +1168,18 @@ def test_given_row_visible_in_public_view_when_updated_to_be_not_visible_event_s
                 {
                     # The row should appear as a deleted event as for the public view
                     # it effectively has been.
-                    "type": "row_deleted",
+                    "type": "rows_deleted",
                     "table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
-                    "row_id": initially_visible_row.id,
-                    "row": {
-                        "id": initially_visible_row.id,
-                        "order": "1.00000000000000000000",
-                        # Only the visible field should be sent in its state before it
-                        # was updated
-                        f"field_{visible_field.id}": "Visible",
-                    },
+                    "row_ids": [initially_visible_row.id],
+                    "rows": [
+                        {
+                            "id": initially_visible_row.id,
+                            "order": "1.00000000000000000000",
+                            # Only the visible field should be sent in its state before
+                            # it was updated
+                            f"field_{visible_field.id}": "Visible",
+                        }
+                    ],
                 },
                 None,
             ),
@@ -1353,20 +1369,24 @@ def test_given_row_visible_in_public_view_when_updated_to_still_be_visible_event
             call(
                 f"view-{public_view_with_row_showing.slug}",
                 {
-                    "type": "row_updated",
+                    "type": "rows_updated",
                     "table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
-                    "row_before_update": {
-                        "id": initially_visible_row.id,
-                        "order": "1.00000000000000000000",
-                        # Only the visible field should be sent
-                        f"field_{visible_field.id}": "Visible",
-                    },
-                    "row": {
-                        "id": initially_visible_row.id,
-                        "order": "1.00000000000000000000",
-                        # Only the visible field should be sent
-                        f"field_{visible_field.id}": "StillVisibleButUpdated",
-                    },
+                    "rows_before_update": [
+                        {
+                            "id": initially_visible_row.id,
+                            "order": "1.00000000000000000000",
+                            # Only the visible field should be sent
+                            f"field_{visible_field.id}": "Visible",
+                        }
+                    ],
+                    "rows": [
+                        {
+                            "id": initially_visible_row.id,
+                            "order": "1.00000000000000000000",
+                            # Only the visible field should be sent
+                            f"field_{visible_field.id}": "StillVisibleButUpdated",
+                        }
+                    ],
                     "metadata": {},
                 },
                 None,
@@ -1620,14 +1640,16 @@ def test_when_row_restored_public_views_receive_restricted_row_created_ws_event(
             call(
                 f"view-{public_view_only_showing_one_field.slug}",
                 {
-                    "type": "row_created",
+                    "type": "rows_created",
                     "table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
-                    "row": {
-                        "id": row.id,
-                        "order": "1.00000000000000000000",
-                        # Only the visible field should be sent
-                        f"field_{visible_field.id}": "Visible",
-                    },
+                    "rows": [
+                        {
+                            "id": row.id,
+                            "order": "1.00000000000000000000",
+                            # Only the visible field should be sent
+                            f"field_{visible_field.id}": "Visible",
+                        }
+                    ],
                     "metadata": {},
                     "before_row_id": None,
                 },
@@ -1636,16 +1658,18 @@ def test_when_row_restored_public_views_receive_restricted_row_created_ws_event(
             call(
                 f"view-{public_view_showing_all_fields.slug}",
                 {
-                    "type": "row_created",
+                    "type": "rows_created",
                     "table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
-                    "row": {
-                        "id": row.id,
-                        "order": "1.00000000000000000000",
-                        f"field_{visible_field.id}": "Visible",
-                        # This field is not hidden for this public view and so should be
-                        # included
-                        f"field_{hidden_field.id}": "Hidden",
-                    },
+                    "rows": [
+                        {
+                            "id": row.id,
+                            "order": "1.00000000000000000000",
+                            f"field_{visible_field.id}": "Visible",
+                            # This field is not hidden for this public view and so
+                            # should be included
+                            f"field_{hidden_field.id}": "Hidden",
+                        }
+                    ],
                     "metadata": {},
                     "before_row_id": None,
                 },
@@ -1715,14 +1739,16 @@ def test_when_row_restored_public_views_receive_row_created_only_when_filters_ma
             call(
                 f"view-{public_view_showing_row.slug}",
                 {
-                    "type": "row_created",
+                    "type": "rows_created",
                     "table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
-                    "row": {
-                        "id": row.id,
-                        "order": "1.00000000000000000000",
-                        # Only the visible field should be sent
-                        f"field_{visible_field.id}": "Visible",
-                    },
+                    "rows": [
+                        {
+                            "id": row.id,
+                            "order": "1.00000000000000000000",
+                            # Only the visible field should be sent
+                            f"field_{visible_field.id}": "Visible",
+                        }
+                    ],
                     "metadata": {},
                     "before_row_id": None,
                 },
@@ -1900,20 +1926,24 @@ def test_given_row_visible_in_public_view_when_moved_row_updated_sent(
                 {
                     # The row should appear as a deleted event as for the public view
                     # it effectively has been.
-                    "type": "row_updated",
+                    "type": "rows_updated",
                     "table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
-                    "row_before_update": {
-                        "id": visible_moving_row.id,
-                        "order": "1.00000000000000000000",
-                        # Only the visible field should be sent
-                        f"field_{visible_field.id}": "Visible",
-                    },
-                    "row": {
-                        "id": visible_moving_row.id,
-                        "order": "0.99999999999999999999",
-                        # Only the visible field should be sent
-                        f"field_{visible_field.id}": "Visible",
-                    },
+                    "rows_before_update": [
+                        {
+                            "id": visible_moving_row.id,
+                            "order": "1.00000000000000000000",
+                            # Only the visible field should be sent
+                            f"field_{visible_field.id}": "Visible",
+                        }
+                    ],
+                    "rows": [
+                        {
+                            "id": visible_moving_row.id,
+                            "order": "0.99999999999999999999",
+                            # Only the visible field should be sent
+                            f"field_{visible_field.id}": "Visible",
+                        }
+                    ],
                     "metadata": {},
                 },
                 None,
diff --git a/backend/tests/baserow/contrib/database/ws/test_ws_rows_signals.py b/backend/tests/baserow/contrib/database/ws/test_ws_rows_signals.py
index 7bca872ff..dfa96de2f 100644
--- a/backend/tests/baserow/contrib/database/ws/test_ws_rows_signals.py
+++ b/backend/tests/baserow/contrib/database/ws/test_ws_rows_signals.py
@@ -28,11 +28,11 @@ def test_row_created(mock_broadcast_to_channel_group, data_fixture):
     mock_broadcast_to_channel_group.delay.assert_called_once()
     args = mock_broadcast_to_channel_group.delay.call_args
     assert args[0][0] == f"table-{table.id}"
-    assert args[0][1]["type"] == "row_created"
+    assert args[0][1]["type"] == "rows_created"
     assert args[0][1]["table_id"] == table.id
-    assert args[0][1]["row"]["id"] == row.id
+    assert args[0][1]["rows"][0]["id"] == row.id
     assert args[0][1]["before_row_id"] is None
-    assert args[0][1]["row"][f"field_{field.id}"] == "Test"
+    assert args[0][1]["rows"][0][f"field_{field.id}"] == "Test"
     assert args[0][1]["metadata"] == {}
 
     row_2 = RowHandler().create_row(
@@ -40,11 +40,11 @@ def test_row_created(mock_broadcast_to_channel_group, data_fixture):
     )
     args = mock_broadcast_to_channel_group.delay.call_args
     assert args[0][0] == f"table-{table.id}"
-    assert args[0][1]["type"] == "row_created"
+    assert args[0][1]["type"] == "rows_created"
     assert args[0][1]["table_id"] == table.id
-    assert args[0][1]["row"]["id"] == row_2.id
+    assert args[0][1]["rows"][0]["id"] == row_2.id
     assert args[0][1]["before_row_id"] == row.id
-    assert args[0][1]["row"][f"field_{field.id}"] == "Test2"
+    assert args[0][1]["rows"][0][f"field_{field.id}"] == "Test2"
     assert args[0][1]["metadata"] == {}
 
 
@@ -64,12 +64,12 @@ def test_row_created_with_metadata(mock_broadcast_to_channel_group, data_fixture
     mock_broadcast_to_channel_group.delay.assert_called_once()
     args = mock_broadcast_to_channel_group.delay.call_args
     assert args[0][0] == f"table-{table.id}"
-    assert args[0][1]["type"] == "row_created"
+    assert args[0][1]["type"] == "rows_created"
     assert args[0][1]["table_id"] == table.id
-    assert args[0][1]["row"]["id"] == row.id
+    assert args[0][1]["rows"][0]["id"] == row.id
     assert args[0][1]["before_row_id"] is None
-    assert args[0][1]["row"][f"field_{field.id}"] == "Test"
-    assert args[0][1]["metadata"] == {"row_id": row.id}
+    assert args[0][1]["rows"][0][f"field_{field.id}"] == "Test"
+    assert args[0][1]["metadata"] == {1: {"row_id": row.id}}
 
 
 def test_populates_with_row_id_metadata():
@@ -102,14 +102,14 @@ def test_row_updated(mock_broadcast_to_channel_group, data_fixture):
     mock_broadcast_to_channel_group.delay.assert_called_once()
     args = mock_broadcast_to_channel_group.delay.call_args
     assert args[0][0] == f"table-{table.id}"
-    assert args[0][1]["type"] == "row_updated"
+    assert args[0][1]["type"] == "rows_updated"
     assert args[0][1]["table_id"] == table.id
-    assert args[0][1]["row_before_update"]["id"] == row.id
-    assert args[0][1]["row_before_update"][f"field_{field.id}"] is None
-    assert args[0][1]["row_before_update"][f"field_{field_2.id}"] is None
-    assert args[0][1]["row"]["id"] == row.id
-    assert args[0][1]["row"][f"field_{field.id}"] == "Test"
-    assert args[0][1]["row"][f"field_{field_2.id}"] is None
+    assert args[0][1]["rows_before_update"][0]["id"] == row.id
+    assert args[0][1]["rows_before_update"][0][f"field_{field.id}"] is None
+    assert args[0][1]["rows_before_update"][0][f"field_{field_2.id}"] is None
+    assert args[0][1]["rows"][0]["id"] == row.id
+    assert args[0][1]["rows"][0][f"field_{field.id}"] == "Test"
+    assert args[0][1]["rows"][0][f"field_{field_2.id}"] is None
     assert args[0][1]["metadata"] == {}
 
     row.refresh_from_db()
@@ -121,11 +121,11 @@ def test_row_updated(mock_broadcast_to_channel_group, data_fixture):
 
     args = mock_broadcast_to_channel_group.delay.call_args
     assert args[0][0] == f"table-{table.id}"
-    assert args[0][1]["type"] == "row_updated"
+    assert args[0][1]["type"] == "rows_updated"
     assert args[0][1]["table_id"] == table.id
-    assert args[0][1]["row"]["id"] == row.id
-    assert args[0][1]["row"][f"field_{field.id}"] == "First"
-    assert args[0][1]["row"][f"field_{field_2.id}"] == "Second"
+    assert args[0][1]["rows"][0]["id"] == row.id
+    assert args[0][1]["rows"][0][f"field_{field.id}"] == "First"
+    assert args[0][1]["rows"][0][f"field_{field_2.id}"] == "Second"
     assert args[0][1]["metadata"] == {}
 
 
@@ -148,15 +148,15 @@ def test_row_updated_with_metadata(mock_broadcast_to_channel_group, data_fixture
     mock_broadcast_to_channel_group.delay.assert_called_once()
     args = mock_broadcast_to_channel_group.delay.call_args
     assert args[0][0] == f"table-{table.id}"
-    assert args[0][1]["type"] == "row_updated"
+    assert args[0][1]["type"] == "rows_updated"
     assert args[0][1]["table_id"] == table.id
-    assert args[0][1]["row_before_update"]["id"] == row.id
-    assert args[0][1]["row_before_update"][f"field_{field.id}"] is None
-    assert args[0][1]["row_before_update"][f"field_{field_2.id}"] is None
-    assert args[0][1]["row"]["id"] == row.id
-    assert args[0][1]["row"][f"field_{field.id}"] == "Test"
-    assert args[0][1]["row"][f"field_{field_2.id}"] is None
-    assert args[0][1]["metadata"] == {"row_id": row.id}
+    assert args[0][1]["rows_before_update"][0]["id"] == row.id
+    assert args[0][1]["rows_before_update"][0][f"field_{field.id}"] is None
+    assert args[0][1]["rows_before_update"][0][f"field_{field_2.id}"] is None
+    assert args[0][1]["rows"][0]["id"] == row.id
+    assert args[0][1]["rows"][0][f"field_{field.id}"] == "Test"
+    assert args[0][1]["rows"][0][f"field_{field_2.id}"] is None
+    assert args[0][1]["metadata"] == {1: {"row_id": row.id}}
 
 
 @pytest.mark.django_db(transaction=True)
@@ -174,8 +174,8 @@ def test_row_deleted(mock_broadcast_to_channel_group, data_fixture):
     mock_broadcast_to_channel_group.delay.assert_called_once()
     args = mock_broadcast_to_channel_group.delay.call_args
     assert args[0][0] == f"table-{table.id}"
-    assert args[0][1]["type"] == "row_deleted"
-    assert args[0][1]["row_id"] == row_id
+    assert args[0][1]["type"] == "rows_deleted"
+    assert args[0][1]["row_ids"] == [row_id]
     assert args[0][1]["table_id"] == table.id
-    assert args[0][1]["row"]["id"] == row_id
-    assert args[0][1]["row"][f"field_{field.id}"] == "Value"
+    assert args[0][1]["rows"][0]["id"] == row_id
+    assert args[0][1]["rows"][0][f"field_{field.id}"] == "Value"
diff --git a/changelog.md b/changelog.md
index 3a0b3098c..893f881e4 100644
--- a/changelog.md
+++ b/changelog.md
@@ -24,6 +24,12 @@ For example:
 
 ### Breaking Changes
 
+* **breaking change** Webhooks `row.created`, `row.updated` and `row.deleted` are
+  replaced with `rows.created`, `rows.updated` and `rows.deleted`, containing multiple
+  changed rows at once. Already created webhooks will still be called, but the received
+  body will contain only the first changed row instead of all rows. It is highly
+  recommended to convert all webhooks to the new types.
+
 
 ## Released (2022-07-05 1.10.2)
 
@@ -75,7 +81,6 @@ For example:
 * Fix get_human_readable_value crashing for some formula types. [#1042](https://gitlab.com/bramw/baserow/-/issues/1042)
 * Fix import form that gets stuck in a spinning state when it hits an error.
 
-
 ### Breaking Changes
 
 
diff --git a/docs/apis/web-socket-api.md b/docs/apis/web-socket-api.md
index e1fcb38d5..d91bc5c43 100644
--- a/docs/apis/web-socket-api.md
+++ b/docs/apis/web-socket-api.md
@@ -145,13 +145,9 @@ are subscribed to the page.
 * `field_updated`
 * `field_deleted`
 * `field_restored`
-* `row_created`
 * `rows_created`
-* `row_updated`
 * `rows_updated`
-* `row_deleted`
-* `before_row_update`
-* `before_row_delete`
+* `rows_deleted`
 * `before_rows_update`
 * `before_rows_delete`
 * `view_created`
diff --git a/web-frontend/locales/en.json b/web-frontend/locales/en.json
index d673003a9..39ed85cec 100644
--- a/web-frontend/locales/en.json
+++ b/web-frontend/locales/en.json
@@ -175,8 +175,11 @@
         },
         "eventType": {
             "rowCreated": "When a row is created",
+            "rowsCreated": "Rows are created",
             "rowUpdated": "When a row is updated",
-            "rowDeleted": "When a row is deleted"
+            "rowsUpdated": "Rows are updated",
+            "rowDeleted": "When a row is deleted",
+            "rowsDeleted": "Rows are deleted"
         }
     },
     "clientHandler": {
diff --git a/web-frontend/modules/core/assets/scss/components/webhook.scss b/web-frontend/modules/core/assets/scss/components/webhook.scss
index 204af4d5b..808316155 100644
--- a/web-frontend/modules/core/assets/scss/components/webhook.scss
+++ b/web-frontend/modules/core/assets/scss/components/webhook.scss
@@ -306,3 +306,8 @@
     color: $color-error-500;
   }
 }
+
+.webhook__convert {
+  display: block;
+  margin-top: 20px;
+}
diff --git a/web-frontend/modules/database/components/webhook/WebhookForm.vue b/web-frontend/modules/database/components/webhook/WebhookForm.vue
index ab615cb3f..acbe1ffc8 100644
--- a/web-frontend/modules/database/components/webhook/WebhookForm.vue
+++ b/web-frontend/modules/database/components/webhook/WebhookForm.vue
@@ -1,221 +1,248 @@
 <template>
   <form @submit.prevent="submit" @input="$emit('formchange')">
-    <div
-      v-if="!values.active"
-      class="alert alert--simple alert--primary alert--has-icon"
-    >
-      <div class="alert__icon">
-        <i class="fas fa-exclamation"></i>
-      </div>
-      <div class="alert__title">{{ $t('webhookForm.deactivated.title') }}</div>
-      <p class="alert__content">
-        {{ $t('webhookForm.deactivated.content') }}
-      </p>
-      <a
-        class="button button--ghost margin-top-1"
-        @click="values.active = true"
-        >{{ $t('webhookForm.deactivated.activate') }}</a
+    <div v-if="!isDeprecated">
+      <div
+        v-if="!values.active"
+        class="alert alert--simple alert--primary alert--has-icon"
       >
-    </div>
-    <div class="row">
-      <div class="col col-12">
-        <FormElement :error="fieldHasErrors('name')" class="control">
-          <label class="control__label">
-            {{ $t('webhookForm.inputLabels.name') }}
-          </label>
-          <div class="control__elements">
-            <input
-              v-model="values.name"
-              class="input"
-              :class="{ 'input--error': fieldHasErrors('name') }"
-              @blur="$v.values.name.$touch()"
-            />
-            <div v-if="fieldHasErrors('name')" class="error">
-              {{ $t('error.requiredField') }}
-            </div>
-          </div>
-        </FormElement>
-      </div>
-      <div class="col col-12">
-        <div class="control">
-          <label class="control__label">
-            {{ $t('webhookForm.inputLabels.userFieldNames') }}
-          </label>
-          <div class="control__elements">
-            <Checkbox v-model="values.use_user_field_names">{{
-              $t('webhookForm.checkbox.sendUserFieldNames')
-            }}</Checkbox>
-          </div>
+        <div class="alert__icon">
+          <i class="fas fa-exclamation"></i>
         </div>
-      </div>
-      <div class="col col-4">
-        <div class="control">
-          <div class="control__label">
-            {{ $t('webhookForm.inputLabels.requestMethod') }}
-          </div>
-          <div class="control__elements">
-            <Dropdown v-model="values.request_method">
-              <DropdownItem name="GET" value="GET"></DropdownItem>
-              <DropdownItem name="POST" value="POST"></DropdownItem>
-              <DropdownItem name="PATCH" value="PATCH"></DropdownItem>
-              <DropdownItem name="PUT" value="PUT"></DropdownItem>
-              <DropdownItem name="DELETE" value="DELETE"></DropdownItem>
-            </Dropdown>
-          </div>
+        <div class="alert__title">
+          {{ $t('webhookForm.deactivated.title') }}
         </div>
-      </div>
-      <div class="col col-8">
-        <FormElement :error="fieldHasErrors('url')" class="control">
-          <label class="control__label">
-            {{ $t('webhookForm.inputLabels.url') }}
-          </label>
-          <div class="control__elements">
-            <input
-              v-model="values.url"
-              :placeholder="$t('webhookForm.inputLabels.url')"
-              class="input"
-              :class="{ 'input--error': fieldHasErrors('url') }"
-              @blur="$v.values.url.$touch()"
-            />
-            <div
-              v-if="
-                fieldHasErrors('url') &&
-                (!$v.values.url.required || !$v.values.url.url)
-              "
-              class="error"
-            >
-              {{ $t('webhookForm.errors.urlField') }}
-            </div>
-            <div
-              v-else-if="$v.values.url.$error && !$v.values.url.maxLength"
-              class="error"
-            >
-              {{
-                $t('error.maxLength', {
-                  max: $v.values.url.$params.maxLength.max,
-                })
-              }}
-            </div>
-          </div>
-        </FormElement>
-      </div>
-    </div>
-    <div class="control">
-      <label class="control__label">
-        {{ $t('webhookForm.inputLabels.events') }}
-      </label>
-      <div class="control__elements">
-        <Radio v-model="values.include_all_events" :value="true">{{
-          $t('webhookForm.radio.allEvents')
-        }}</Radio>
-        <Radio v-model="values.include_all_events" :value="false">
-          {{ $t('webhookForm.radio.customEvents') }}
-        </Radio>
-        <div v-if="!values.include_all_events" class="webhook__types">
-          <Checkbox
-            v-for="webhookEvent in webhookEventTypes"
-            :key="webhookEvent.type"
-            :value="values.events.includes(webhookEvent.type)"
-            class="webhook__type"
-            @input="
-              $event
-                ? values.events.push(webhookEvent.type)
-                : values.events.splice(
-                    values.events.indexOf(webhookEvent.type),
-                    1
-                  )
-            "
-            >{{ webhookEvent.getName() }}</Checkbox
-          >
-        </div>
-      </div>
-    </div>
-    <div class="control">
-      <div class="control__label">
-        {{ $t('webhookForm.inputLabels.headers') }}
-      </div>
-      <div class="control__elements">
-        <div
-          v-for="(header, index) in headers.concat({
-            name: '',
-            value: '',
-          })"
-          :key="`header-input-${index}`"
-          class="webhook__header"
+        <p class="alert__content">
+          {{ $t('webhookForm.deactivated.content') }}
+        </p>
+        <a
+          class="button button--ghost margin-top-1"
+          @click="values.active = true"
+          >{{ $t('webhookForm.deactivated.activate') }}</a
         >
-          <div class="webhook__header-row">
-            <input
-              v-model="header.name"
-              class="input webhook__header-key"
-              :class="{
-                'input--error':
-                  !lastHeader(index) && $v.headers.$each[index].name.$error,
-              }"
-              :placeholder="$t('webhookForm.inputLabels.name')"
-              @input="lastHeader(index) && addHeader(header.name, header.value)"
-              @blur="
-                !lastHeader(index) && $v.headers.$each[index].name.$touch()
-              "
-            />
-            <input
-              v-model="header.value"
-              class="input webhook__header-value"
-              :class="{
-                'input--error':
-                  !lastHeader(index) && $v.headers.$each[index].value.$error,
-              }"
-              :placeholder="$t('webhookForm.inputLabels.value')"
-              @input="lastHeader(index) && addHeader(header.name, header.value)"
-              @blur="
-                !lastHeader(index) && $v.headers.$each[index].value.$touch()
-              "
-            />
-            <a
-              v-if="!lastHeader(index)"
-              class="button button--error webhook__header-delete"
-              @click="removeHeader(index)"
-            >
-              <i class="fas fa-trash button__icon"></i>
-            </a>
+      </div>
+      <div class="row">
+        <div class="col col-12">
+          <FormElement :error="fieldHasErrors('name')" class="control">
+            <label class="control__label">
+              {{ $t('webhookForm.inputLabels.name') }}
+            </label>
+            <div class="control__elements">
+              <input
+                v-model="values.name"
+                class="input"
+                :class="{ 'input--error': fieldHasErrors('name') }"
+                @blur="$v.values.name.$touch()"
+              />
+              <div v-if="fieldHasErrors('name')" class="error">
+                {{ $t('error.requiredField') }}
+              </div>
+            </div>
+          </FormElement>
+        </div>
+        <div class="col col-12">
+          <div class="control">
+            <label class="control__label">
+              {{ $t('webhookForm.inputLabels.userFieldNames') }}
+            </label>
+            <div class="control__elements">
+              <Checkbox v-model="values.use_user_field_names">{{
+                $t('webhookForm.checkbox.sendUserFieldNames')
+              }}</Checkbox>
+            </div>
           </div>
         </div>
-        <div v-if="$v.headers.$anyError" class="error">
-          {{ $t('webhookForm.errors.invalidHeaders') }}
+        <div class="col col-4">
+          <div class="control">
+            <div class="control__label">
+              {{ $t('webhookForm.inputLabels.requestMethod') }}
+            </div>
+            <div class="control__elements">
+              <Dropdown v-model="values.request_method">
+                <DropdownItem name="GET" value="GET"></DropdownItem>
+                <DropdownItem name="POST" value="POST"></DropdownItem>
+                <DropdownItem name="PATCH" value="PATCH"></DropdownItem>
+                <DropdownItem name="PUT" value="PUT"></DropdownItem>
+                <DropdownItem name="DELETE" value="DELETE"></DropdownItem>
+              </Dropdown>
+            </div>
+          </div>
         </div>
+        <div class="col col-8">
+          <FormElement :error="fieldHasErrors('url')" class="control">
+            <label class="control__label">
+              {{ $t('webhookForm.inputLabels.url') }}
+            </label>
+            <div class="control__elements">
+              <input
+                v-model="values.url"
+                :placeholder="$t('webhookForm.inputLabels.url')"
+                class="input"
+                :class="{ 'input--error': fieldHasErrors('url') }"
+                @blur="$v.values.url.$touch()"
+              />
+              <div
+                v-if="
+                  fieldHasErrors('url') &&
+                  (!$v.values.url.required || !$v.values.url.url)
+                "
+                class="error"
+              >
+                {{ $t('webhookForm.errors.urlField') }}
+              </div>
+              <div
+                v-else-if="$v.values.url.$error && !$v.values.url.maxLength"
+                class="error"
+              >
+                {{
+                  $t('error.maxLength', {
+                    max: $v.values.url.$params.maxLength.max,
+                  })
+                }}
+              </div>
+            </div>
+          </FormElement>
+        </div>
+      </div>
+      <div class="control">
+        <label class="control__label">
+          {{ $t('webhookForm.inputLabels.events') }}
+        </label>
+        <div class="control__elements">
+          <Radio v-model="values.include_all_events" :value="true">{{
+            $t('webhookForm.radio.allEvents')
+          }}</Radio>
+          <Radio v-model="values.include_all_events" :value="false">
+            {{ $t('webhookForm.radio.customEvents') }}
+          </Radio>
+          <div v-if="!values.include_all_events" class="webhook__types">
+            <Checkbox
+              v-for="webhookEvent in webhookEventTypes"
+              :key="webhookEvent.type"
+              :value="values.events.includes(webhookEvent.type)"
+              class="webhook__type"
+              @input="
+                $event
+                  ? values.events.push(webhookEvent.type)
+                  : values.events.splice(
+                      values.events.indexOf(webhookEvent.type),
+                      1
+                    )
+              "
+              >{{ webhookEvent.getName() }}</Checkbox
+            >
+          </div>
+        </div>
+      </div>
+      <div class="control">
+        <div class="control__label">
+          {{ $t('webhookForm.inputLabels.headers') }}
+        </div>
+        <div class="control__elements">
+          <div
+            v-for="(header, index) in headers.concat({
+              name: '',
+              value: '',
+            })"
+            :key="`header-input-${index}`"
+            class="webhook__header"
+          >
+            <div class="webhook__header-row">
+              <input
+                v-model="header.name"
+                class="input webhook__header-key"
+                :class="{
+                  'input--error':
+                    !lastHeader(index) && $v.headers.$each[index].name.$error,
+                }"
+                :placeholder="$t('webhookForm.inputLabels.name')"
+                @input="
+                  lastHeader(index) && addHeader(header.name, header.value)
+                "
+                @blur="
+                  !lastHeader(index) && $v.headers.$each[index].name.$touch()
+                "
+              />
+              <input
+                v-model="header.value"
+                class="input webhook__header-value"
+                :class="{
+                  'input--error':
+                    !lastHeader(index) && $v.headers.$each[index].value.$error,
+                }"
+                :placeholder="$t('webhookForm.inputLabels.value')"
+                @input="
+                  lastHeader(index) && addHeader(header.name, header.value)
+                "
+                @blur="
+                  !lastHeader(index) && $v.headers.$each[index].value.$touch()
+                "
+              />
+              <a
+                v-if="!lastHeader(index)"
+                class="button button--error webhook__header-delete"
+                @click="removeHeader(index)"
+              >
+                <i class="fas fa-trash button__icon"></i>
+              </a>
+            </div>
+          </div>
+          <div v-if="$v.headers.$anyError" class="error">
+            {{ $t('webhookForm.errors.invalidHeaders') }}
+          </div>
+        </div>
+      </div>
+      <div class="control">
+        <div class="control__label">
+          {{ $t('webhookForm.inputLabels.example') }}
+        </div>
+        <div class="control__elements">
+          <div class="webhook__code-with-dropdown">
+            <div class="webhook__code-dropdown">
+              <Dropdown
+                v-model="exampleWebhookEventType"
+                class="dropdown--floating-left"
+              >
+                <DropdownItem
+                  v-for="webhookEvent in webhookEventTypes"
+                  :key="webhookEvent.type"
+                  :name="webhookEvent.getName()"
+                  :value="webhookEvent.type"
+                ></DropdownItem>
+              </Dropdown>
+            </div>
+            <div class="webhook__code-container">
+              <pre
+                class="webhook__code"
+              ><code>{{ JSON.stringify(testExample, null, 4)}}</code></pre>
+            </div>
+          </div>
+        </div>
+      </div>
+      <a class="button button--ghost" @click="openTestModal()">{{
+        $t('webhookForm.triggerButton')
+      }}</a>
+      <slot></slot>
+      <TestWebhookModal ref="testModal" />
+    </div>
+    <div v-else>
+      <div class="alert alert--error alert--has-icon">
+        <div class="alert__icon">
+          <i class="fas fa-exclamation"></i>
+        </div>
+        <div class="alert__title">
+          {{ $t('webhookForm.deprecatedEventType.title') }}
+        </div>
+        <p class="alert__content">
+          {{ $t('webhookForm.deprecatedEventType.description') }}
+          <button
+            class="button webhook__convert"
+            @click="convertFromDeprecated"
+          >
+            {{ $t('webhookForm.deprecatedEventType.convert') }}
+          </button>
+        </p>
       </div>
     </div>
-    <div class="control">
-      <div class="control__label">
-        {{ $t('webhookForm.inputLabels.example') }}
-      </div>
-      <div class="control__elements">
-        <div class="webhook__code-with-dropdown">
-          <div class="webhook__code-dropdown">
-            <Dropdown
-              v-model="exampleWebhookEventType"
-              class="dropdown--floating-left"
-            >
-              <DropdownItem
-                v-for="webhookEvent in webhookEventTypes"
-                :key="webhookEvent.type"
-                :name="webhookEvent.getName()"
-                :value="webhookEvent.type"
-              ></DropdownItem>
-            </Dropdown>
-          </div>
-          <div class="webhook__code-container">
-            <pre
-              class="webhook__code"
-            ><code>{{ JSON.stringify(testExample, null, 4)}}</code></pre>
-          </div>
-        </div>
-      </div>
-    </div>
-    <a class="button button--ghost" @click="openTestModal()">{{
-      $t('webhookForm.triggerButton')
-    }}</a>
-    <slot></slot>
-    <TestWebhookModal ref="testModal" />
   </form>
 </template>
 
@@ -272,6 +299,11 @@ export default {
     webhookEventTypes() {
       return this.$registry.getAll('webhookEvent')
     },
+    isDeprecated() {
+      return this.values.events.some((eventName) =>
+        ['row.created', 'row.updated', 'row.deleted'].includes(eventName)
+      )
+    },
     /**
      * Generates an example payload of the webhook event based on the chosen webhook
      * event type.
@@ -376,6 +408,12 @@ export default {
     lastHeader(index) {
       return index === this.headers.length
     },
+    convertFromDeprecated() {
+      this.values.events = this.values.events.map((eventName) =>
+        eventName.replace('row.', 'rows.')
+      )
+      this.submit()
+    },
   },
 }
 </script>
diff --git a/web-frontend/modules/database/locales/en.json b/web-frontend/modules/database/locales/en.json
index c2990663e..41aecde50 100644
--- a/web-frontend/modules/database/locales/en.json
+++ b/web-frontend/modules/database/locales/en.json
@@ -43,6 +43,11 @@
             "title": "Webhook is deactivated",
             "content": "This webhook has been deactivated because there have been too many consecutive failures. Please check the call log for more details. Click on the button below to activate it again. Don't forgot to save the webhook after activating.",
             "activate": "Activate"
+        },
+        "deprecatedEventType": {
+            "title": "Deprecated event type",
+            "description": "This webhook doesn't receive information about all changed rows at once. Please convert it to batch-style event type. This changes the JSON body payload to a format that contains multiple rows.",
+            "convert": "Convert"
         }
     },
     "webhook": {
diff --git a/web-frontend/modules/database/plugin.js b/web-frontend/modules/database/plugin.js
index 175dcb889..77be63ebd 100644
--- a/web-frontend/modules/database/plugin.js
+++ b/web-frontend/modules/database/plugin.js
@@ -64,9 +64,9 @@ import {
   JSONImporterType,
 } from '@baserow/modules/database/importerTypes'
 import {
-  RowCreatedWebhookEventType,
-  RowUpdatedWebhookEventType,
-  RowDeletedWebhookEventType,
+  RowsCreatedWebhookEventType,
+  RowsUpdatedWebhookEventType,
+  RowsDeletedWebhookEventType,
 } from '@baserow/modules/database/webhookEventTypes'
 import {
   ImageFilePreview,
@@ -321,15 +321,15 @@ export default (context) => {
   app.$registry.register('exporter', new CSVTableExporterType(context))
   app.$registry.register(
     'webhookEvent',
-    new RowCreatedWebhookEventType(context)
+    new RowsCreatedWebhookEventType(context)
   )
   app.$registry.register(
     'webhookEvent',
-    new RowUpdatedWebhookEventType(context)
+    new RowsUpdatedWebhookEventType(context)
   )
   app.$registry.register(
     'webhookEvent',
-    new RowDeletedWebhookEventType(context)
+    new RowsDeletedWebhookEventType(context)
   )
 
   // Text functions
diff --git a/web-frontend/modules/database/realtime.js b/web-frontend/modules/database/realtime.js
index a78dc1668..55de93be7 100644
--- a/web-frontend/modules/database/realtime.js
+++ b/web-frontend/modules/database/realtime.js
@@ -155,21 +155,6 @@ export const registerRealtimeEvents = (realtime) => {
     }
   })
 
-  realtime.registerEvent('row_created', (context, data) => {
-    const { app, store } = context
-    for (const viewType of Object.values(app.$registry.getAll('view'))) {
-      viewType.rowCreated(
-        context,
-        data.table_id,
-        store.getters['field/getAll'],
-        store.getters['field/getPrimary'],
-        data.row,
-        data.metadata,
-        'page/'
-      )
-    }
-  })
-
   realtime.registerEvent('rows_created', (context, data) => {
     const { app, store } = context
     for (const viewType of Object.values(app.$registry.getAll('view'))) {
@@ -187,30 +172,7 @@ export const registerRealtimeEvents = (realtime) => {
     }
   })
 
-  realtime.registerEvent('row_updated', async (context, data) => {
-    const { app, store } = context
-    for (const viewType of Object.values(app.$registry.getAll('view'))) {
-      await viewType.rowUpdated(
-        context,
-        data.table_id,
-        store.getters['field/getAll'],
-        store.getters['field/getPrimary'],
-        data.row_before_update,
-        data.row,
-        data.metadata,
-        'page/'
-      )
-    }
-
-    store.dispatch('rowModal/updated', {
-      tableId: data.table_id,
-      values: data.row,
-    })
-  })
-
   realtime.registerEvent('rows_updated', async (context, data) => {
-    // TODO: Rewrite
-    // This is currently a naive implementation of batch rows updates.
     const { app, store } = context
     for (const viewType of Object.values(app.$registry.getAll('view'))) {
       for (let i = 0; i < data.rows.length; i++) {
@@ -234,20 +196,6 @@ export const registerRealtimeEvents = (realtime) => {
     }
   })
 
-  realtime.registerEvent('row_deleted', (context, data) => {
-    const { app, store } = context
-    for (const viewType of Object.values(app.$registry.getAll('view'))) {
-      viewType.rowDeleted(
-        context,
-        data.table_id,
-        store.getters['field/getAll'],
-        store.getters['field/getPrimary'],
-        data.row,
-        'page/'
-      )
-    }
-  })
-
   realtime.registerEvent('rows_deleted', (context, data) => {
     const { app, store } = context
     for (const viewType of Object.values(app.$registry.getAll('view'))) {
diff --git a/web-frontend/modules/database/webhookEventTypes.js b/web-frontend/modules/database/webhookEventTypes.js
index 621d5b0e0..a69c78129 100644
--- a/web-frontend/modules/database/webhookEventTypes.js
+++ b/web-frontend/modules/database/webhookEventTypes.js
@@ -32,56 +32,54 @@ export class WebhookEventType extends Registerable {
   }
 }
 
-export class RowCreatedWebhookEventType extends WebhookEventType {
+export class RowsCreatedWebhookEventType extends WebhookEventType {
   getType() {
-    return 'row.created'
+    return 'rows.created'
   }
 
   getName() {
     const { i18n } = this.app
-    return i18n.t('webhook.eventType.rowCreated')
+    return i18n.t('webhook.eventType.rowsCreated')
   }
 
   getExamplePayload(table, rowExample) {
     const payload = super.getExamplePayload(table, rowExample)
-    payload.row_id = rowExample.id
-    payload.values = rowExample
+    payload.items = [rowExample]
     return payload
   }
 }
 
-export class RowUpdatedWebhookEventType extends WebhookEventType {
+export class RowsUpdatedWebhookEventType extends WebhookEventType {
   getType() {
-    return 'row.updated'
+    return 'rows.updated'
   }
 
   getName() {
     const { i18n } = this.app
-    return i18n.t('webhook.eventType.rowUpdated')
+    return i18n.t('webhook.eventType.rowsUpdated')
   }
 
   getExamplePayload(table, rowExample) {
     const payload = super.getExamplePayload(table, rowExample)
-    payload.row_id = rowExample.id
-    payload.values = rowExample
-    payload.old_values = rowExample
+    payload.items = [rowExample]
+    payload.old_items = [rowExample]
     return payload
   }
 }
 
-export class RowDeletedWebhookEventType extends WebhookEventType {
+export class RowsDeletedWebhookEventType extends WebhookEventType {
   getType() {
-    return 'row.deleted'
+    return 'rows.deleted'
   }
 
   getName() {
     const { i18n } = this.app
-    return i18n.t('webhook.eventType.rowDeleted')
+    return i18n.t('webhook.eventType.rowsDeleted')
   }
 
   getExamplePayload(table, rowExample) {
     const payload = super.getExamplePayload(table, rowExample)
-    payload.row_id = rowExample.id
+    payload.row_ids = [rowExample.id]
     return payload
   }
 }