From fa9048f584c43b1a6817d6b9847e14c1b8214cc5 Mon Sep 17 00:00:00 2001
From: Bram Wiepjes <bramw@protonmail.com>
Date: Tue, 25 Feb 2025 10:52:35 +0000
Subject: [PATCH] Send notification when webhook is deactivated

---
 .../builder/locale/en/LC_MESSAGES/django.po   |  4 +-
 backend/src/baserow/contrib/database/apps.py  |  4 +
 .../database/locale/en/LC_MESSAGES/django.po  | 28 +++++--
 .../database/webhooks/notification_types.py   | 81 +++++++++++++++++++
 .../contrib/database/webhooks/tasks.py        |  9 +++
 .../core/locale/en/LC_MESSAGES/django.po      |  4 +-
 .../baserow/core/notifications_summary.html   | 18 -----
 .../core/notifications_summary.mjml.eta       |  6 --
 .../test_webhook_notification_types.py        | 37 +++++++++
 .../database/webhooks/test_webhook_tasks.py   | 69 ++++++++++++++++
 ...ication_when_a_webhook_is_deactivated.json |  7 ++
 .../locale/en/LC_MESSAGES/django.po           | 11 ++-
 .../WebhookDeactivatedNotification.vue        | 29 +++++++
 .../components/webhook/WebhookModal.vue       |  2 +-
 web-frontend/modules/database/locales/en.json |  3 +
 .../modules/database/notificationTypes.js     | 25 ++++++
 web-frontend/modules/database/pages/table.vue |  1 +
 .../modules/database/pages/table/webhooks.vue | 37 +++++++++
 web-frontend/modules/database/plugin.js       |  5 ++
 web-frontend/modules/database/routes.js       |  5 ++
 web-frontend/test/helpers/components.js       |  1 +
 21 files changed, 348 insertions(+), 38 deletions(-)
 create mode 100644 backend/src/baserow/contrib/database/webhooks/notification_types.py
 create mode 100644 backend/tests/baserow/contrib/database/webhooks/test_webhook_notification_types.py
 create mode 100644 changelog/entries/unreleased/feature/send_notification_when_a_webhook_is_deactivated.json
 create mode 100644 web-frontend/modules/database/components/notifications/WebhookDeactivatedNotification.vue
 create mode 100644 web-frontend/modules/database/pages/table/webhooks.vue

diff --git a/backend/src/baserow/contrib/builder/locale/en/LC_MESSAGES/django.po b/backend/src/baserow/contrib/builder/locale/en/LC_MESSAGES/django.po
index 306c9b390..d5b6ada07 100644
--- a/backend/src/baserow/contrib/builder/locale/en/LC_MESSAGES/django.po
+++ b/backend/src/baserow/contrib/builder/locale/en/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: PACKAGE VERSION\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2025-01-15 11:59+0000\n"
+"POT-Creation-Date: 2025-02-11 17:20+0000\n"
 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -46,7 +46,7 @@ msgstr ""
 msgid "Last name"
 msgstr ""
 
-#: src/baserow/contrib/builder/data_providers/data_provider_types.py:452
+#: src/baserow/contrib/builder/data_providers/data_provider_types.py:555
 #, python-format
 msgid "%(user_source_name)s member"
 msgstr ""
diff --git a/backend/src/baserow/contrib/database/apps.py b/backend/src/baserow/contrib/database/apps.py
index 3149bf2fe..a460bfe91 100755
--- a/backend/src/baserow/contrib/database/apps.py
+++ b/backend/src/baserow/contrib/database/apps.py
@@ -946,6 +946,9 @@ class DatabaseConfig(AppConfig):
         from baserow.contrib.database.views.notification_types import (
             FormSubmittedNotificationType,
         )
+        from baserow.contrib.database.webhooks.notification_types import (
+            WebhookDeactivatedNotificationType,
+        )
         from baserow.core.notifications.registries import notification_type_registry
 
         notification_type_registry.register(CollaboratorAddedToRowNotificationType())
@@ -953,6 +956,7 @@ class DatabaseConfig(AppConfig):
             UserMentionInRichTextFieldNotificationType()
         )
         notification_type_registry.register(FormSubmittedNotificationType())
+        notification_type_registry.register(WebhookDeactivatedNotificationType())
 
         # The signals must always be imported last because they use the registries
         # which need to be filled first.
diff --git a/backend/src/baserow/contrib/database/locale/en/LC_MESSAGES/django.po b/backend/src/baserow/contrib/database/locale/en/LC_MESSAGES/django.po
index cf1987b19..75eb3173a 100644
--- a/backend/src/baserow/contrib/database/locale/en/LC_MESSAGES/django.po
+++ b/backend/src/baserow/contrib/database/locale/en/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: PACKAGE VERSION\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2025-01-15 11:59+0000\n"
+"POT-Creation-Date: 2025-02-11 17:20+0000\n"
 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -38,11 +38,11 @@ msgid ""
 "\"%(database_name)s\" (%(database_id)s)."
 msgstr ""
 
-#: src/baserow/contrib/database/airtable/actions.py:22
+#: src/baserow/contrib/database/airtable/actions.py:23
 msgid "Import database from Airtable"
 msgstr ""
 
-#: src/baserow/contrib/database/airtable/actions.py:24
+#: src/baserow/contrib/database/airtable/actions.py:25
 #, python-format
 msgid ""
 "Imported database "
@@ -80,7 +80,7 @@ msgstr ""
 msgid "The data sync synchronized"
 msgstr ""
 
-#: src/baserow/contrib/database/data_sync/handler.py:186
+#: src/baserow/contrib/database/data_sync/handler.py:187
 #: src/baserow/contrib/database/table/handler.py:548
 msgid "Grid"
 msgstr ""
@@ -148,8 +148,8 @@ msgid ""
 "%(new_primary_field_name)s"
 msgstr ""
 
-#: src/baserow/contrib/database/fields/models.py:415
-#: src/baserow/contrib/database/fields/models.py:594
+#: src/baserow/contrib/database/fields/models.py:453
+#: src/baserow/contrib/database/fields/models.py:632
 msgid "The format of the duration."
 msgstr ""
 
@@ -601,12 +601,12 @@ msgstr ""
 msgid "Row (%(row_id)s) created via form submission"
 msgstr ""
 
-#: src/baserow/contrib/database/views/notification_types.py:84
+#: src/baserow/contrib/database/views/notification_types.py:86
 #, python-format
 msgid "%(form_name)s has been submitted in %(table_name)s"
 msgstr ""
 
-#: src/baserow/contrib/database/views/notification_types.py:101
+#: src/baserow/contrib/database/views/notification_types.py:103
 #, python-format
 msgid "and 1 more field"
 msgid_plural "and %(count)s more fields"
@@ -645,3 +645,15 @@ msgid ""
 "Webhook \"%(webhook_name)s\" (%(webhook_id)s) as %(webhook_request_method)s "
 "to %(webhook_url)s\" updated"
 msgstr ""
+
+#: src/baserow/contrib/database/webhooks/notification_types.py:70
+#, python-format
+msgid "%(name)s webhook has been deactivated."
+msgstr ""
+
+#: src/baserow/contrib/database/webhooks/notification_types.py:77
+#, python-format
+msgid ""
+"The webhook failed more than %(max_failures)s consecutive times and was "
+"therefore deactivated."
+msgstr ""
diff --git a/backend/src/baserow/contrib/database/webhooks/notification_types.py b/backend/src/baserow/contrib/database/webhooks/notification_types.py
new file mode 100644
index 000000000..e11c15d52
--- /dev/null
+++ b/backend/src/baserow/contrib/database/webhooks/notification_types.py
@@ -0,0 +1,81 @@
+from dataclasses import asdict, dataclass
+from typing import List, Optional
+
+from django.conf import settings
+from django.utils.translation import gettext as _
+
+from baserow.core.models import WORKSPACE_USER_PERMISSION_ADMIN, WorkspaceUser
+from baserow.core.notifications.handler import NotificationHandler
+from baserow.core.notifications.models import NotificationRecipient
+from baserow.core.notifications.registries import (
+    EmailNotificationTypeMixin,
+    NotificationType,
+)
+
+from .models import TableWebhook
+
+
+@dataclass
+class DeactivatedWebhookData:
+    webhook_id: int
+    table_id: int
+    database_id: int
+    webhook_name: str
+
+    @classmethod
+    def from_webhook(cls, webhook):
+        return cls(
+            webhook_id=webhook.id,
+            table_id=webhook.table_id,
+            database_id=webhook.table.database_id,
+            webhook_name=webhook.name,
+        )
+
+
+class WebhookDeactivatedNotificationType(EmailNotificationTypeMixin, NotificationType):
+    type = "webhook_deactivated"
+    has_web_frontend_route = True
+
+    @classmethod
+    def notify_admins_in_workspace(
+        cls, webhook: TableWebhook
+    ) -> Optional[List[NotificationRecipient]]:
+        """
+        Creates a notification for each user that is subscribed to receive comments on
+        the row on which the comment was created.
+
+        :param webhook: The comment that was created.
+        :return:
+        """
+
+        workspace = webhook.table.database.workspace
+        admins_workspace_users = WorkspaceUser.objects.filter(
+            workspace=workspace,
+            permissions=WORKSPACE_USER_PERMISSION_ADMIN,
+            user__profile__to_be_deleted=False,
+            user__is_active=True,
+        ).select_related("user")
+        admins_in_workspace = [admin.user for admin in admins_workspace_users]
+
+        return NotificationHandler.create_direct_notification_for_users(
+            notification_type=WebhookDeactivatedNotificationType.type,
+            recipients=admins_in_workspace,
+            data=asdict(DeactivatedWebhookData.from_webhook(webhook)),
+            sender=None,
+            workspace=webhook.table.database.workspace,
+        )
+
+    @classmethod
+    def get_notification_title_for_email(cls, notification, context):
+        return _("%(name)s webhook has been deactivated.") % {
+            "name": notification.data["webhook_name"],
+        }
+
+    @classmethod
+    def get_notification_description_for_email(cls, notification, context):
+        return _(
+            "The webhook failed more than %(max_failures)s consecutive times and "
+            "was therefore deactivated."
+        ) % {
+            "max_failures": settings.BASEROW_WEBHOOKS_MAX_CONSECUTIVE_TRIGGER_FAILURES,
+        }
diff --git a/backend/src/baserow/contrib/database/webhooks/tasks.py b/backend/src/baserow/contrib/database/webhooks/tasks.py
index 223865416..e4bec1d88 100644
--- a/backend/src/baserow/contrib/database/webhooks/tasks.py
+++ b/backend/src/baserow/contrib/database/webhooks/tasks.py
@@ -88,6 +88,7 @@ def call_webhook(
 
     from .handler import WebhookHandler
     from .models import TableWebhook, TableWebhookCall
+    from .notification_types import WebhookDeactivatedNotificationType
 
     if self.request.retries > retries:
         retries = self.request.retries
@@ -187,6 +188,14 @@ def call_webhook(
                 webhook.active = False
                 webhook.save()
 
+                # Send a notification to the workspace admins that the webhook was
+                # deactivated.
+                transaction.on_commit(
+                    lambda: WebhookDeactivatedNotificationType.notify_admins_in_workspace(
+                        webhook
+                    )
+                )
+
             # After the transaction successfully commits we can delay the next call
             # in the queue, so that only one call is triggered concurrently.
             transaction.on_commit(lambda: schedule_next_task_in_queue(webhook_id))
diff --git a/backend/src/baserow/core/locale/en/LC_MESSAGES/django.po b/backend/src/baserow/core/locale/en/LC_MESSAGES/django.po
index 5c3844515..7a41c9d85 100644
--- a/backend/src/baserow/core/locale/en/LC_MESSAGES/django.po
+++ b/backend/src/baserow/core/locale/en/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: PACKAGE VERSION\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2025-01-15 11:59+0000\n"
+"POT-Creation-Date: 2025-02-11 17:20+0000\n"
 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -242,7 +242,7 @@ msgstr ""
 msgid "Decimal number"
 msgstr ""
 
-#: src/baserow/core/handler.py:2109 src/baserow/core/user/handler.py:267
+#: src/baserow/core/handler.py:2122 src/baserow/core/user/handler.py:267
 #, python-format
 msgid "%(name)s's workspace"
 msgstr ""
diff --git a/backend/src/baserow/core/templates/baserow/core/notifications_summary.html b/backend/src/baserow/core/templates/baserow/core/notifications_summary.html
index e26965f31..27429a172 100644
--- a/backend/src/baserow/core/templates/baserow/core/notifications_summary.html
+++ b/backend/src/baserow/core/templates/baserow/core/notifications_summary.html
@@ -230,24 +230,6 @@
                     </tr>
                     <!-- htmlmin:ignore -->{% endif %}
                     <!-- htmlmin:ignore -->
-                    <tr>
-                      <td align="left" vertical-align="middle" style="font-size:0px;padding:10px 25px;word-break:break-word;">
-                        <table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
-                          <tbody>
-                            <tr>
-                              <td align="center" bgcolor="#5190ef" role="presentation" style="border:none;border-radius:4px;cursor:auto;mso-padding-alt:12px 30px;background:#5190ef;" valign="middle">
-                                <a href="{{ baserow_embedded_share_url }}" style="display:inline-block;background:#5190ef;color:#ffffff;font-family:Inter,sans-serif;font-size:15px;font-weight:600;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:12px 30px;mso-padding-alt:0px;border-radius:4px;" target="_blank"> {% trans "View in Baserow" %} </a>
-                              </td>
-                            </tr>
-                          </tbody>
-                        </table>
-                      </td>
-                    </tr>
-                    <tr>
-                      <td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
-                        <div style="font-family:Inter,sans-serif;font-size:12px;line-height:1;text-align:left;color:#9c9c9f;">{{ baserow_embedded_share_url }}</div>
-                      </td>
-                    </tr>
                     <tr>
                       <td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
                         <div style="font-family:Inter,sans-serif;font-size:13px;line-height:170%;text-align:left;color:#070810;">{% blocktrans trimmed %} Baserow is an open source no-code database tool which allows you to collaborate on projects, customers and more. It gives you the powers of a developer without leaving your browser. {% endblocktrans %}</div>
diff --git a/backend/src/baserow/core/templates/baserow/core/notifications_summary.mjml.eta b/backend/src/baserow/core/templates/baserow/core/notifications_summary.mjml.eta
index 30d79e603..36a0b4f56 100644
--- a/backend/src/baserow/core/templates/baserow/core/notifications_summary.mjml.eta
+++ b/backend/src/baserow/core/templates/baserow/core/notifications_summary.mjml.eta
@@ -31,12 +31,6 @@
         {% endblocktrans %}
         </mj-text>
       <mj-raw><!-- htmlmin:ignore -->{% endif %}<!-- htmlmin:ignore --></mj-raw>
-    <mj-button mj-class="button mt-20" href="{{ baserow_embedded_share_url }}">
-      {% trans "View in Baserow" %}
-    </mj-button>
-    <mj-text mj-class="button-url">
-      {{ baserow_embedded_share_url }}
-    </mj-text>
     <mj-text mj-class="text">
       {% blocktrans trimmed %}
         Baserow is an open source no-code database tool which allows you to collaborate
diff --git a/backend/tests/baserow/contrib/database/webhooks/test_webhook_notification_types.py b/backend/tests/baserow/contrib/database/webhooks/test_webhook_notification_types.py
new file mode 100644
index 000000000..55b545e90
--- /dev/null
+++ b/backend/tests/baserow/contrib/database/webhooks/test_webhook_notification_types.py
@@ -0,0 +1,37 @@
+import pytest
+
+from baserow.contrib.database.webhooks.notification_types import (
+    WebhookDeactivatedNotificationType,
+)
+
+
+@pytest.mark.django_db(transaction=True)
+def test_webhook_deactivated_notification_can_be_render_as_email(
+    api_client, data_fixture
+):
+    user = data_fixture.create_user()
+    workspace = data_fixture.create_workspace(user=user)
+    database = data_fixture.create_database_application(workspace=workspace)
+    table = data_fixture.create_database_table(database=database)
+    webhook = data_fixture.create_table_webhook(
+        table=table, active=True, failed_triggers=1
+    )
+
+    notification_recipients = (
+        WebhookDeactivatedNotificationType.notify_admins_in_workspace(webhook)
+    )
+    notification = notification_recipients[0].notification
+
+    assert WebhookDeactivatedNotificationType.get_notification_title_for_email(
+        notification, {}
+    ) == "%(name)s webhook has been deactivated." % {
+        "name": notification.data["webhook_name"],
+    }
+
+    assert (
+        WebhookDeactivatedNotificationType.get_notification_description_for_email(
+            notification, {}
+        )
+        == "The webhook failed more than 8 consecutive times and "
+        "was therefore deactivated."
+    )
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 4532e61d0..08ccb63b5 100644
--- a/backend/tests/baserow/contrib/database/webhooks/test_webhook_tasks.py
+++ b/backend/tests/baserow/contrib/database/webhooks/test_webhook_tasks.py
@@ -10,7 +10,12 @@ import responses
 from celery.exceptions import Retry
 
 from baserow.contrib.database.webhooks.models import TableWebhook, TableWebhookCall
+from baserow.contrib.database.webhooks.notification_types import (
+    WebhookDeactivatedNotificationType,
+)
 from baserow.contrib.database.webhooks.tasks import call_webhook
+from baserow.core.models import WorkspaceUser
+from baserow.core.notifications.models import Notification
 from baserow.core.redis import RedisQueue
 from baserow.test_utils.helpers import stub_getaddrinfo
 
@@ -361,3 +366,67 @@ def test_can_call_webhook_to_localhost_when_private_addresses_allowed(
     assert not call.error
     assert call.response_status == 201
     assert webhook.active
+
+
+@pytest.mark.django_db(transaction=True)
+@responses.activate
+@override_settings(
+    BASEROW_WEBHOOKS_MAX_RETRIES_PER_CALL=1,
+    BASEROW_WEBHOOKS_MAX_CONSECUTIVE_TRIGGER_FAILURES=1,
+)
+@patch("baserow.contrib.database.webhooks.tasks.RedisQueue", MemoryQueue)
+@patch("baserow.contrib.database.webhooks.tasks.cache", MagicMock())
+@patch("baserow.ws.tasks.broadcast_to_users.apply")
+def test_call_webhook_failed_reached_notification_send(
+    mocked_broadcast_to_users, data_fixture
+):
+    user_1 = data_fixture.create_user()
+    user_2 = data_fixture.create_user()
+    admin_1 = data_fixture.create_user()
+    admin_2 = data_fixture.create_user()
+    workspace = data_fixture.create_workspace()
+
+    WorkspaceUser.objects.create(
+        user=user_1, workspace=workspace, order=1, permissions="MEMBER"
+    )
+    WorkspaceUser.objects.create(
+        user=user_2, workspace=workspace, order=2, permissions="MEMBER"
+    )
+    WorkspaceUser.objects.create(
+        user=admin_1, workspace=workspace, order=3, permissions="ADMIN"
+    )
+    WorkspaceUser.objects.create(
+        user=admin_2, workspace=workspace, order=4, permissions="ADMIN"
+    )
+
+    database = data_fixture.create_database_application(workspace=workspace)
+    table = data_fixture.create_database_table(database=database)
+    webhook = data_fixture.create_table_webhook(
+        table=table, active=True, failed_triggers=1
+    )
+
+    call_webhook.push_request(retries=1)
+    call_webhook.run(
+        webhook_id=webhook.id,
+        event_id="00000000-0000-0000-0000-000000000000",
+        event_type="rows.created",
+        method="POST",
+        url="http://localhost/",
+        headers={"Baserow-header-1": "Value 1"},
+        payload={"type": "rows.created"},
+    )
+
+    all_notifications = list(Notification.objects.all())
+    assert len(all_notifications) == 1
+    recipient_ids = [r.id for r in all_notifications[0].recipients.all()]
+    assert recipient_ids == [admin_1.id, admin_2.id]
+    assert all_notifications[0].type == WebhookDeactivatedNotificationType.type
+    assert all_notifications[0].broadcast is False
+    assert all_notifications[0].workspace_id == workspace.id
+    assert all_notifications[0].sender is None
+    assert all_notifications[0].data == {
+        "database_id": database.id,
+        "table_id": table.id,
+        "webhook_id": webhook.id,
+        "webhook_name": webhook.name,
+    }
diff --git a/changelog/entries/unreleased/feature/send_notification_when_a_webhook_is_deactivated.json b/changelog/entries/unreleased/feature/send_notification_when_a_webhook_is_deactivated.json
new file mode 100644
index 000000000..8782d0a45
--- /dev/null
+++ b/changelog/entries/unreleased/feature/send_notification_when_a_webhook_is_deactivated.json
@@ -0,0 +1,7 @@
+{
+  "type": "feature",
+  "message": "Send notification to all admins when a webhook is deactivated.",
+  "issue_number": null,
+  "bullet_points": [],
+  "created_at": "2025-02-11"
+}
diff --git a/enterprise/backend/src/baserow_enterprise/locale/en/LC_MESSAGES/django.po b/enterprise/backend/src/baserow_enterprise/locale/en/LC_MESSAGES/django.po
index 689290852..f6f380813 100644
--- a/enterprise/backend/src/baserow_enterprise/locale/en/LC_MESSAGES/django.po
+++ b/enterprise/backend/src/baserow_enterprise/locale/en/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: PACKAGE VERSION\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2024-12-17 08:48+0000\n"
+"POT-Creation-Date: 2025-02-11 17:20+0000\n"
 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -62,6 +62,15 @@ msgstr ""
 msgid "REDONE"
 msgstr ""
 
+#: src/baserow_enterprise/data_sync/actions.py:21
+msgid "Update periodic data sync interval"
+msgstr ""
+
+#: src/baserow_enterprise/data_sync/actions.py:22
+#, python-format
+msgid "Data sync table \"%(table_name)s\" (%(table_id)s) updated"
+msgstr ""
+
 #: src/baserow_enterprise/role/actions.py:28
 msgid "Assign multiple roles"
 msgstr ""
diff --git a/web-frontend/modules/database/components/notifications/WebhookDeactivatedNotification.vue b/web-frontend/modules/database/components/notifications/WebhookDeactivatedNotification.vue
new file mode 100644
index 000000000..7ec787bcd
--- /dev/null
+++ b/web-frontend/modules/database/components/notifications/WebhookDeactivatedNotification.vue
@@ -0,0 +1,29 @@
+<template>
+  <nuxt-link
+    class="notification-panel__notification-link"
+    :to="route"
+    @click.native="markAsReadAndHandleClick"
+  >
+    <div class="notification-panel__notification-content-title">
+      <i18n path="webhookDeactivatedNotification.body" tag="span">
+        <template #name>
+          <strong>{{ notification.data.webhook_name }}</strong>
+        </template>
+      </i18n>
+    </div>
+  </nuxt-link>
+</template>
+
+<script>
+import notificationContent from '@baserow/modules/core/mixins/notificationContent'
+
+export default {
+  name: 'WebhookDeactivatedNotification',
+  mixins: [notificationContent],
+  methods: {
+    handleClick() {
+      this.$emit('close-panel')
+    },
+  },
+}
+</script>
diff --git a/web-frontend/modules/database/components/webhook/WebhookModal.vue b/web-frontend/modules/database/components/webhook/WebhookModal.vue
index edeefa357..4b60f0db8 100644
--- a/web-frontend/modules/database/components/webhook/WebhookModal.vue
+++ b/web-frontend/modules/database/components/webhook/WebhookModal.vue
@@ -1,5 +1,5 @@
 <template>
-  <Modal>
+  <Modal @hidden="$emit('hidden')">
     <h2 class="box__title">
       {{ $t('webhookModal.title', { name: table.name }) }}
     </h2>
diff --git a/web-frontend/modules/database/locales/en.json b/web-frontend/modules/database/locales/en.json
index 65fb0d147..ff711f42e 100644
--- a/web-frontend/modules/database/locales/en.json
+++ b/web-frontend/modules/database/locales/en.json
@@ -1054,5 +1054,8 @@
   "configureDataSyncSettings": {
     "title": "Change data sync",
     "syncTable": "Sync when save"
+  },
+  "webhookDeactivatedNotification": {
+    "body": "{name} webhook has been deactivated because it failed too many times consecutively."
   }
 }
diff --git a/web-frontend/modules/database/notificationTypes.js b/web-frontend/modules/database/notificationTypes.js
index 17090201a..787153f68 100644
--- a/web-frontend/modules/database/notificationTypes.js
+++ b/web-frontend/modules/database/notificationTypes.js
@@ -3,6 +3,7 @@ import NotificationSenderInitialsIcon from '@baserow/modules/core/components/not
 import CollaboratorAddedToRowNotification from '@baserow/modules/database/components/notifications/CollaboratorAddedToRowNotification'
 import UserMentionInRichTextFieldNotification from '@baserow/modules/database/components/notifications/UserMentionInRichTextFieldNotification'
 import FormSubmittedNotification from '@baserow/modules/database/components/notifications/FormSubmittedNotification'
+import WebhookDeactivatedNotification from '@baserow/modules/database/components/notifications/WebhookDeactivatedNotification'
 
 export class CollaboratorAddedToRowNotificationType extends NotificationType {
   static getType() {
@@ -78,3 +79,27 @@ export class UserMentionInRichTextFieldNotificationType extends NotificationType
     }
   }
 }
+
+export class WebhookDeactivatedNotificationType extends NotificationType {
+  static getType() {
+    return 'webhook_deactivated'
+  }
+
+  getIconComponent() {
+    return null
+  }
+
+  getContentComponent() {
+    return WebhookDeactivatedNotification
+  }
+
+  getRoute(notificationData) {
+    return {
+      name: 'database-table-open-webhooks',
+      params: {
+        databaseId: notificationData.database_id,
+        tableId: notificationData.table_id,
+      },
+    }
+  }
+}
diff --git a/web-frontend/modules/database/pages/table.vue b/web-frontend/modules/database/pages/table.vue
index 5fd1c56dc..8196d303d 100644
--- a/web-frontend/modules/database/pages/table.vue
+++ b/web-frontend/modules/database/pages/table.vue
@@ -17,6 +17,7 @@
         (row, activeSearchTerm) => setAdjacentRow(false, row, activeSearchTerm)
       "
     ></Table>
+    <NuxtChild :database="database" :table="table" :fields="fields" />
   </div>
 </template>
 
diff --git a/web-frontend/modules/database/pages/table/webhooks.vue b/web-frontend/modules/database/pages/table/webhooks.vue
new file mode 100644
index 000000000..2beca9afc
--- /dev/null
+++ b/web-frontend/modules/database/pages/table/webhooks.vue
@@ -0,0 +1,37 @@
+<template>
+  <div>
+    <WebhookModal
+      ref="webhookModal"
+      :database="database"
+      :table="table"
+      :fields="fields"
+      @hidden="$router.push({ name: 'database-table', params: $route.params })"
+    />
+  </div>
+</template>
+<script>
+import WebhookModal from '@baserow/modules/database/components/webhook/WebhookModal'
+
+export default {
+  components: { WebhookModal },
+  props: {
+    database: {
+      type: Object,
+      required: true,
+    },
+    table: {
+      type: Object,
+      required: true,
+    },
+    fields: {
+      type: Array,
+      required: true,
+    },
+  },
+  mounted() {
+    this.$nextTick(() => {
+      this.$refs.webhookModal.show()
+    })
+  },
+}
+</script>
diff --git a/web-frontend/modules/database/plugin.js b/web-frontend/modules/database/plugin.js
index 81080b112..85709d98c 100644
--- a/web-frontend/modules/database/plugin.js
+++ b/web-frontend/modules/database/plugin.js
@@ -319,6 +319,7 @@ import {
   CollaboratorAddedToRowNotificationType,
   FormSubmittedNotificationType,
   UserMentionInRichTextFieldNotificationType,
+  WebhookDeactivatedNotificationType,
 } from '@baserow/modules/database/notificationTypes'
 import { HistoryRowModalSidebarType } from '@baserow/modules/database/rowModalSidebarTypes'
 import { FieldsDataProviderType } from '@baserow/modules/database/dataProviderTypes'
@@ -1019,6 +1020,10 @@ export default (context) => {
     'notification',
     new UserMentionInRichTextFieldNotificationType(context)
   )
+  app.$registry.register(
+    'notification',
+    new WebhookDeactivatedNotificationType(context)
+  )
 
   app.$registry.register(
     'rowModalSidebar',
diff --git a/web-frontend/modules/database/routes.js b/web-frontend/modules/database/routes.js
index f256948da..5d0364791 100644
--- a/web-frontend/modules/database/routes.js
+++ b/web-frontend/modules/database/routes.js
@@ -23,6 +23,11 @@ export const routes = [
         path: 'row/:rowId',
         name: 'database-table-row',
       },
+      {
+        path: 'webhooks',
+        name: 'database-table-open-webhooks',
+        component: path.resolve(__dirname, 'pages/table/webhooks.vue'),
+      },
     ],
   },
   // These redirect exist because the original api docs path was `/api/docs`, but
diff --git a/web-frontend/test/helpers/components.js b/web-frontend/test/helpers/components.js
index e5b8c6617..150f169f4 100644
--- a/web-frontend/test/helpers/components.js
+++ b/web-frontend/test/helpers/components.js
@@ -39,6 +39,7 @@ export const bootstrapVueContext = (configureContext) => {
   jest.isolateModules(() => {
     context.vueTestUtils = require('@vue/test-utils')
     context.vueTestUtils.config.stubs.nuxt = { template: '<div />' }
+    context.vueTestUtils.config.stubs.NuxtChild = { template: '<div />' }
     context.vueTestUtils.config.stubs['nuxt-link'] = {
       template: '<a><slot /></a>',
     }