diff --git a/CHANGELOG.md b/CHANGELOG.md index ef2a1a10..781e807a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to this project will be documented in this file. - Add a section in Docs about running self-hosted instances - Add experimental Dockerfile and docker-compose.yml - Add rate limiting for Pushover notifications (6 notifications / user / minute) +- Add the WEBHOOKS_ENABLED setting (#471) ## Bug Fixes - Fix unwanted HTML escaping in SMS and WhatsApp notifications diff --git a/docker/.env b/docker/.env index 161796e6..d96a3e17 100644 --- a/docker/.env +++ b/docker/.env @@ -53,3 +53,4 @@ TWILIO_AUTH= TWILIO_FROM= TWILIO_USE_WHATSAPP=False USE_PAYMENTS=False +WEBHOOKS_ENABLED=True \ No newline at end of file diff --git a/hc/api/tests/test_notify.py b/hc/api/tests/test_notify.py index 847928e9..3a22c228 100644 --- a/hc/api/tests/test_notify.py +++ b/hc/api/tests/test_notify.py @@ -8,7 +8,7 @@ from django.core import mail from django.utils.timezone import now from hc.api.models import Channel, Check, Notification, TokenBucket from hc.test import BaseTestCase -from requests.exceptions import ConnectionError, Timeout +from requests.exceptions import Timeout from django.test.utils import override_settings @@ -26,309 +26,6 @@ class NotifyTestCase(BaseTestCase): self.channel.save() self.channel.checks.add(self.check) - @patch("hc.api.transports.requests.request") - def test_webhook(self, mock_get): - definition = { - "method_down": "GET", - "url_down": "http://example", - "body_down": "", - "headers_down": {}, - } - - self._setup_data("webhook", json.dumps(definition)) - mock_get.return_value.status_code = 200 - - self.channel.notify(self.check) - mock_get.assert_called_with( - "get", - "http://example", - headers={"User-Agent": "healthchecks.io"}, - timeout=5, - ) - - @patch("hc.api.transports.requests.request", side_effect=Timeout) - def test_webhooks_handle_timeouts(self, mock_get): - definition = { - "method_down": "GET", - "url_down": "http://example", - "body_down": "", - "headers_down": {}, - } - - self._setup_data("webhook", json.dumps(definition)) - self.channel.notify(self.check) - - # The transport should have retried 3 times - self.assertEqual(mock_get.call_count, 3) - - n = Notification.objects.get() - self.assertEqual(n.error, "Connection timed out") - - self.channel.refresh_from_db() - self.assertEqual(self.channel.last_error, "Connection timed out") - - @patch("hc.api.transports.requests.request", side_effect=ConnectionError) - def test_webhooks_handle_connection_errors(self, mock_get): - definition = { - "method_down": "GET", - "url_down": "http://example", - "body_down": "", - "headers_down": {}, - } - self._setup_data("webhook", json.dumps(definition)) - self.channel.notify(self.check) - - # The transport should have retried 3 times - self.assertEqual(mock_get.call_count, 3) - - n = Notification.objects.get() - self.assertEqual(n.error, "Connection failed") - - @patch("hc.api.transports.requests.request") - def test_webhooks_handle_500(self, mock_get): - definition = { - "method_down": "GET", - "url_down": "http://example", - "body_down": "", - "headers_down": {}, - } - - self._setup_data("webhook", json.dumps(definition)) - mock_get.return_value.status_code = 500 - - self.channel.notify(self.check) - - # The transport should have retried 3 times - self.assertEqual(mock_get.call_count, 3) - - n = Notification.objects.get() - self.assertEqual(n.error, "Received status code 500") - - @patch("hc.api.transports.requests.request", side_effect=Timeout) - def test_webhooks_dont_retry_when_sending_test_notifications(self, mock_get): - definition = { - "method_down": "GET", - "url_down": "http://example", - "body_down": "", - "headers_down": {}, - } - - self._setup_data("webhook", json.dumps(definition)) - self.channel.notify(self.check, is_test=True) - - # is_test flag is set, the transport should not retry: - self.assertEqual(mock_get.call_count, 1) - - n = Notification.objects.get() - self.assertEqual(n.error, "Connection timed out") - - @patch("hc.api.transports.requests.request") - def test_webhooks_support_variables(self, mock_get): - definition = { - "method_down": "GET", - "url_down": "http://host/$CODE/$STATUS/$TAG1/$TAG2/?name=$NAME", - "body_down": "", - "headers_down": {}, - } - - self._setup_data("webhook", json.dumps(definition)) - self.check.name = "Hello World" - self.check.tags = "foo bar" - self.check.save() - - self.channel.notify(self.check) - - url = "http://host/%s/down/foo/bar/?name=Hello%%20World" % self.check.code - - args, kwargs = mock_get.call_args - self.assertEqual(args[0], "get") - self.assertEqual(args[1], url) - self.assertEqual(kwargs["headers"], {"User-Agent": "healthchecks.io"}) - self.assertEqual(kwargs["timeout"], 5) - - @patch("hc.api.transports.requests.request") - def test_webhooks_handle_variable_variables(self, mock_get): - definition = { - "method_down": "GET", - "url_down": "http://host/$$NAMETAG1", - "body_down": "", - "headers_down": {}, - } - - self._setup_data("webhook", json.dumps(definition)) - self.check.tags = "foo bar" - self.check.save() - - self.channel.notify(self.check) - - # $$NAMETAG1 should *not* get transformed to "foo" - args, kwargs = mock_get.call_args - self.assertEqual(args[1], "http://host/$TAG1") - - @patch("hc.api.transports.requests.request") - def test_webhooks_support_post(self, mock_request): - definition = { - "method_down": "POST", - "url_down": "http://example.com", - "body_down": "The Time Is $NOW", - "headers_down": {}, - } - - self._setup_data("webhook", json.dumps(definition)) - self.check.save() - - self.channel.notify(self.check) - args, kwargs = mock_request.call_args - self.assertEqual(args[0], "post") - self.assertEqual(args[1], "http://example.com") - - # spaces should not have been urlencoded: - payload = kwargs["data"].decode() - self.assertTrue(payload.startswith("The Time Is 2")) - - @patch("hc.api.transports.requests.request") - def test_webhooks_dollarsign_escaping(self, mock_get): - # If name or tag contains what looks like a variable reference, - # that should be left alone: - definition = { - "method_down": "GET", - "url_down": "http://host/$NAME", - "body_down": "", - "headers_down": {}, - } - - self._setup_data("webhook", json.dumps(definition)) - self.check.name = "$TAG1" - self.check.tags = "foo" - self.check.save() - - self.channel.notify(self.check) - - url = "http://host/%24TAG1" - mock_get.assert_called_with( - "get", url, headers={"User-Agent": "healthchecks.io"}, timeout=5 - ) - - @patch("hc.api.transports.requests.request") - def test_webhooks_handle_up_events(self, mock_get): - definition = { - "method_up": "GET", - "url_up": "http://bar", - "body_up": "", - "headers_up": {}, - } - self._setup_data("webhook", json.dumps(definition), status="up") - - self.channel.notify(self.check) - - mock_get.assert_called_with( - "get", "http://bar", headers={"User-Agent": "healthchecks.io"}, timeout=5 - ) - - @patch("hc.api.transports.requests.request") - def test_webhooks_handle_noop_up_events(self, mock_get): - definition = { - "method_up": "GET", - "url_up": "", - "body_up": "", - "headers_up": {}, - } - - self._setup_data("webhook", json.dumps(definition), status="up") - self.channel.notify(self.check) - - self.assertFalse(mock_get.called) - self.assertEqual(Notification.objects.count(), 0) - - @patch("hc.api.transports.requests.request") - def test_webhooks_handle_unicode_post_body(self, mock_request): - definition = { - "method_down": "POST", - "url_down": "http://foo.com", - "body_down": "(╯°□°)╯︵ ┻━┻", - "headers_down": {}, - } - - self._setup_data("webhook", json.dumps(definition)) - self.check.save() - - self.channel.notify(self.check) - args, kwargs = mock_request.call_args - - # unicode should be encoded into utf-8 - self.assertIsInstance(kwargs["data"], bytes) - - @patch("hc.api.transports.requests.request") - def test_webhooks_handle_post_headers(self, mock_request): - definition = { - "method_down": "POST", - "url_down": "http://foo.com", - "body_down": "data", - "headers_down": {"Content-Type": "application/json"}, - } - - self._setup_data("webhook", json.dumps(definition)) - self.channel.notify(self.check) - - headers = {"User-Agent": "healthchecks.io", "Content-Type": "application/json"} - mock_request.assert_called_with( - "post", "http://foo.com", data=b"data", headers=headers, timeout=5 - ) - - @patch("hc.api.transports.requests.request") - def test_webhooks_handle_get_headers(self, mock_request): - definition = { - "method_down": "GET", - "url_down": "http://foo.com", - "body_down": "", - "headers_down": {"Content-Type": "application/json"}, - } - - self._setup_data("webhook", json.dumps(definition)) - self.channel.notify(self.check) - - headers = {"User-Agent": "healthchecks.io", "Content-Type": "application/json"} - mock_request.assert_called_with( - "get", "http://foo.com", headers=headers, timeout=5 - ) - - @patch("hc.api.transports.requests.request") - def test_webhooks_allow_user_agent_override(self, mock_request): - definition = { - "method_down": "GET", - "url_down": "http://foo.com", - "body_down": "", - "headers_down": {"User-Agent": "My-Agent"}, - } - - self._setup_data("webhook", json.dumps(definition)) - self.channel.notify(self.check) - - headers = {"User-Agent": "My-Agent"} - mock_request.assert_called_with( - "get", "http://foo.com", headers=headers, timeout=5 - ) - - @patch("hc.api.transports.requests.request") - def test_webhooks_support_variables_in_headers(self, mock_request): - definition = { - "method_down": "GET", - "url_down": "http://foo.com", - "body_down": "", - "headers_down": {"X-Message": "$NAME is DOWN"}, - } - - self._setup_data("webhook", json.dumps(definition)) - self.check.name = "Foo" - self.check.save() - - self.channel.notify(self.check) - - headers = {"User-Agent": "healthchecks.io", "X-Message": "Foo is DOWN"} - mock_request.assert_called_with( - "get", "http://foo.com", headers=headers, timeout=5 - ) - @patch("hc.api.transports.requests.request") def test_pd(self, mock_post): self._setup_data("pd", "123") diff --git a/hc/api/tests/test_notify_webhook.py b/hc/api/tests/test_notify_webhook.py new file mode 100644 index 00000000..6836e911 --- /dev/null +++ b/hc/api/tests/test_notify_webhook.py @@ -0,0 +1,344 @@ +# coding: utf-8 + +from datetime import timedelta as td +import json +from unittest.mock import patch + +from django.utils.timezone import now +from hc.api.models import Channel, Check, Notification +from hc.test import BaseTestCase +from requests.exceptions import ConnectionError, Timeout +from django.test.utils import override_settings + + +class NotifyWebhookTestCase(BaseTestCase): + def _setup_data(self, value, status="down", email_verified=True): + self.check = Check(project=self.project) + self.check.status = status + self.check.last_ping = now() - td(minutes=61) + self.check.save() + + self.channel = Channel(project=self.project) + self.channel.kind = "webhook" + self.channel.value = value + self.channel.email_verified = email_verified + self.channel.save() + self.channel.checks.add(self.check) + + @patch("hc.api.transports.requests.request") + def test_webhook(self, mock_get): + definition = { + "method_down": "GET", + "url_down": "http://example", + "body_down": "", + "headers_down": {}, + } + + self._setup_data(json.dumps(definition)) + mock_get.return_value.status_code = 200 + + self.channel.notify(self.check) + mock_get.assert_called_with( + "get", + "http://example", + headers={"User-Agent": "healthchecks.io"}, + timeout=5, + ) + + @patch("hc.api.transports.requests.request", side_effect=Timeout) + def test_webhooks_handle_timeouts(self, mock_get): + definition = { + "method_down": "GET", + "url_down": "http://example", + "body_down": "", + "headers_down": {}, + } + + self._setup_data(json.dumps(definition)) + self.channel.notify(self.check) + + # The transport should have retried 3 times + self.assertEqual(mock_get.call_count, 3) + + n = Notification.objects.get() + self.assertEqual(n.error, "Connection timed out") + + self.channel.refresh_from_db() + self.assertEqual(self.channel.last_error, "Connection timed out") + + @patch("hc.api.transports.requests.request", side_effect=ConnectionError) + def test_webhooks_handle_connection_errors(self, mock_get): + definition = { + "method_down": "GET", + "url_down": "http://example", + "body_down": "", + "headers_down": {}, + } + self._setup_data(json.dumps(definition)) + self.channel.notify(self.check) + + # The transport should have retried 3 times + self.assertEqual(mock_get.call_count, 3) + + n = Notification.objects.get() + self.assertEqual(n.error, "Connection failed") + + @patch("hc.api.transports.requests.request") + def test_webhooks_handle_500(self, mock_get): + definition = { + "method_down": "GET", + "url_down": "http://example", + "body_down": "", + "headers_down": {}, + } + + self._setup_data(json.dumps(definition)) + mock_get.return_value.status_code = 500 + + self.channel.notify(self.check) + + # The transport should have retried 3 times + self.assertEqual(mock_get.call_count, 3) + + n = Notification.objects.get() + self.assertEqual(n.error, "Received status code 500") + + @patch("hc.api.transports.requests.request", side_effect=Timeout) + def test_webhooks_dont_retry_when_sending_test_notifications(self, mock_get): + definition = { + "method_down": "GET", + "url_down": "http://example", + "body_down": "", + "headers_down": {}, + } + + self._setup_data(json.dumps(definition)) + self.channel.notify(self.check, is_test=True) + + # is_test flag is set, the transport should not retry: + self.assertEqual(mock_get.call_count, 1) + + n = Notification.objects.get() + self.assertEqual(n.error, "Connection timed out") + + @patch("hc.api.transports.requests.request") + def test_webhooks_support_variables(self, mock_get): + definition = { + "method_down": "GET", + "url_down": "http://host/$CODE/$STATUS/$TAG1/$TAG2/?name=$NAME", + "body_down": "", + "headers_down": {}, + } + + self._setup_data(json.dumps(definition)) + self.check.name = "Hello World" + self.check.tags = "foo bar" + self.check.save() + + self.channel.notify(self.check) + + url = "http://host/%s/down/foo/bar/?name=Hello%%20World" % self.check.code + + args, kwargs = mock_get.call_args + self.assertEqual(args[0], "get") + self.assertEqual(args[1], url) + self.assertEqual(kwargs["headers"], {"User-Agent": "healthchecks.io"}) + self.assertEqual(kwargs["timeout"], 5) + + @patch("hc.api.transports.requests.request") + def test_webhooks_handle_variable_variables(self, mock_get): + definition = { + "method_down": "GET", + "url_down": "http://host/$$NAMETAG1", + "body_down": "", + "headers_down": {}, + } + + self._setup_data(json.dumps(definition)) + self.check.tags = "foo bar" + self.check.save() + + self.channel.notify(self.check) + + # $$NAMETAG1 should *not* get transformed to "foo" + args, kwargs = mock_get.call_args + self.assertEqual(args[1], "http://host/$TAG1") + + @patch("hc.api.transports.requests.request") + def test_webhooks_support_post(self, mock_request): + definition = { + "method_down": "POST", + "url_down": "http://example.com", + "body_down": "The Time Is $NOW", + "headers_down": {}, + } + + self._setup_data(json.dumps(definition)) + self.check.save() + + self.channel.notify(self.check) + args, kwargs = mock_request.call_args + self.assertEqual(args[0], "post") + self.assertEqual(args[1], "http://example.com") + + # spaces should not have been urlencoded: + payload = kwargs["data"].decode() + self.assertTrue(payload.startswith("The Time Is 2")) + + @patch("hc.api.transports.requests.request") + def test_webhooks_dollarsign_escaping(self, mock_get): + # If name or tag contains what looks like a variable reference, + # that should be left alone: + definition = { + "method_down": "GET", + "url_down": "http://host/$NAME", + "body_down": "", + "headers_down": {}, + } + + self._setup_data(json.dumps(definition)) + self.check.name = "$TAG1" + self.check.tags = "foo" + self.check.save() + + self.channel.notify(self.check) + + url = "http://host/%24TAG1" + mock_get.assert_called_with( + "get", url, headers={"User-Agent": "healthchecks.io"}, timeout=5 + ) + + @patch("hc.api.transports.requests.request") + def test_webhooks_handle_up_events(self, mock_get): + definition = { + "method_up": "GET", + "url_up": "http://bar", + "body_up": "", + "headers_up": {}, + } + self._setup_data(json.dumps(definition), status="up") + + self.channel.notify(self.check) + + mock_get.assert_called_with( + "get", "http://bar", headers={"User-Agent": "healthchecks.io"}, timeout=5 + ) + + @patch("hc.api.transports.requests.request") + def test_webhooks_handle_noop_up_events(self, mock_get): + definition = { + "method_up": "GET", + "url_up": "", + "body_up": "", + "headers_up": {}, + } + + self._setup_data(json.dumps(definition), status="up") + self.channel.notify(self.check) + + self.assertFalse(mock_get.called) + self.assertEqual(Notification.objects.count(), 0) + + @patch("hc.api.transports.requests.request") + def test_webhooks_handle_unicode_post_body(self, mock_request): + definition = { + "method_down": "POST", + "url_down": "http://foo.com", + "body_down": "(╯°□°)╯︵ ┻━┻", + "headers_down": {}, + } + + self._setup_data(json.dumps(definition)) + self.check.save() + + self.channel.notify(self.check) + args, kwargs = mock_request.call_args + + # unicode should be encoded into utf-8 + self.assertIsInstance(kwargs["data"], bytes) + + @patch("hc.api.transports.requests.request") + def test_webhooks_handle_post_headers(self, mock_request): + definition = { + "method_down": "POST", + "url_down": "http://foo.com", + "body_down": "data", + "headers_down": {"Content-Type": "application/json"}, + } + + self._setup_data(json.dumps(definition)) + self.channel.notify(self.check) + + headers = {"User-Agent": "healthchecks.io", "Content-Type": "application/json"} + mock_request.assert_called_with( + "post", "http://foo.com", data=b"data", headers=headers, timeout=5 + ) + + @patch("hc.api.transports.requests.request") + def test_webhooks_handle_get_headers(self, mock_request): + definition = { + "method_down": "GET", + "url_down": "http://foo.com", + "body_down": "", + "headers_down": {"Content-Type": "application/json"}, + } + + self._setup_data(json.dumps(definition)) + self.channel.notify(self.check) + + headers = {"User-Agent": "healthchecks.io", "Content-Type": "application/json"} + mock_request.assert_called_with( + "get", "http://foo.com", headers=headers, timeout=5 + ) + + @patch("hc.api.transports.requests.request") + def test_webhooks_allow_user_agent_override(self, mock_request): + definition = { + "method_down": "GET", + "url_down": "http://foo.com", + "body_down": "", + "headers_down": {"User-Agent": "My-Agent"}, + } + + self._setup_data(json.dumps(definition)) + self.channel.notify(self.check) + + headers = {"User-Agent": "My-Agent"} + mock_request.assert_called_with( + "get", "http://foo.com", headers=headers, timeout=5 + ) + + @patch("hc.api.transports.requests.request") + def test_webhooks_support_variables_in_headers(self, mock_request): + definition = { + "method_down": "GET", + "url_down": "http://foo.com", + "body_down": "", + "headers_down": {"X-Message": "$NAME is DOWN"}, + } + + self._setup_data(json.dumps(definition)) + self.check.name = "Foo" + self.check.save() + + self.channel.notify(self.check) + + headers = {"User-Agent": "healthchecks.io", "X-Message": "Foo is DOWN"} + mock_request.assert_called_with( + "get", "http://foo.com", headers=headers, timeout=5 + ) + + @override_settings(WEBHOOKS_ENABLED=False) + def test_it_requires_webhooks_enabled(self): + definition = { + "method_down": "GET", + "url_down": "http://example", + "body_down": "", + "headers_down": {}, + } + + self._setup_data(json.dumps(definition)) + self.channel.notify(self.check) + + n = Notification.objects.get() + self.assertEqual(n.error, "Webhook notifications are not enabled.") diff --git a/hc/api/transports.py b/hc/api/transports.py index c0db179f..854c376f 100644 --- a/hc/api/transports.py +++ b/hc/api/transports.py @@ -232,6 +232,9 @@ class Webhook(HttpTransport): return False def notify(self, check): + if not settings.WEBHOOKS_ENABLED: + return "Webhook notifications are not enabled." + spec = self.channel.webhook_spec(check.status) if not spec["url"]: return "Empty webhook URL" diff --git a/hc/front/tests/test_add_webhook.py b/hc/front/tests/test_add_webhook.py index 0d4d5630..9118793d 100644 --- a/hc/front/tests/test_add_webhook.py +++ b/hc/front/tests/test_add_webhook.py @@ -1,3 +1,4 @@ +from django.test.utils import override_settings from hc.api.models import Channel from hc.test import BaseTestCase @@ -185,3 +186,9 @@ class AddWebhookTestCase(BaseTestCase): self.client.login(username="bob@example.org", password="password") r = self.client.get(self.url) self.assertEqual(r.status_code, 403) + + @override_settings(WEBHOOKS_ENABLED=False) + def test_it_handles_disabled_integration(self): + self.client.login(username="alice@example.org", password="password") + r = self.client.get(self.url) + self.assertEqual(r.status_code, 404) diff --git a/hc/front/views.py b/hc/front/views.py index f9cd2857..e78ad461 100644 --- a/hc/front/views.py +++ b/hc/front/views.py @@ -304,6 +304,7 @@ def index(request): "enable_sms": settings.TWILIO_AUTH is not None, "enable_telegram": settings.TELEGRAM_TOKEN is not None, "enable_trello": settings.TRELLO_APP_KEY is not None, + "enable_webhooks": settings.WEBHOOKS_ENABLED is True, "enable_whatsapp": settings.TWILIO_USE_WHATSAPP, "registration_open": settings.REGISTRATION_OPEN, } @@ -770,6 +771,7 @@ def channels(request, code): "enable_sms": settings.TWILIO_AUTH is not None, "enable_telegram": settings.TELEGRAM_TOKEN is not None, "enable_trello": settings.TRELLO_APP_KEY is not None, + "enable_webhooks": settings.WEBHOOKS_ENABLED is True, "enable_whatsapp": settings.TWILIO_USE_WHATSAPP, "use_payments": settings.USE_PAYMENTS, } @@ -931,6 +933,7 @@ def add_email(request, code): return render(request, "integrations/add_email.html", ctx) +@require_setting("WEBHOOKS_ENABLED") @login_required def add_webhook(request, code): project = _get_rw_project_for_user(request, code) diff --git a/hc/settings.py b/hc/settings.py index a8427596..66f01388 100644 --- a/hc/settings.py +++ b/hc/settings.py @@ -235,6 +235,9 @@ TWILIO_USE_WHATSAPP = envbool("TWILIO_USE_WHATSAPP", "False") # Trello TRELLO_APP_KEY = os.getenv("TRELLO_APP_KEY") +# Webhooks +WEBHOOKS_ENABLED = envbool("WEBHOOKS_ENABLED", "True") + # Read additional configuration from hc/local_settings.py if it exists if os.path.exists(os.path.join(BASE_DIR, "hc/local_settings.py")): from .local_settings import * diff --git a/templates/docs/self_hosted_configuration.html b/templates/docs/self_hosted_configuration.html index b7f78507..0b240f63 100644 --- a/templates/docs/self_hosted_configuration.html +++ b/templates/docs/self_hosted_configuration.html @@ -321,4 +321,7 @@ scheme.
Default: False
USE_PAYMENTS
Default: False
A boolean that turns on/off billing features.
\ No newline at end of file +A boolean that turns on/off billing features.
+WEBHOOKS_ENABLED
Default: True
A boolean that turns on/off the Webhooks integration. Enabled by default.
\ No newline at end of file diff --git a/templates/docs/self_hosted_configuration.md b/templates/docs/self_hosted_configuration.md index b2f2b71e..5aab1258 100644 --- a/templates/docs/self_hosted_configuration.md +++ b/templates/docs/self_hosted_configuration.md @@ -508,4 +508,10 @@ Default: `False` Default: `False` -A boolean that turns on/off billing features. \ No newline at end of file +A boolean that turns on/off billing features. + +## `WEBHOOKS_ENABLED` {: #WEBHOOKS_ENABLED } + +Default: `True` + +A boolean that turns on/off the Webhooks integration. Enabled by default. diff --git a/templates/front/channels.html b/templates/front/channels.html index 8535680e..54e25421 100644 --- a/templates/front/channels.html +++ b/templates/front/channels.html @@ -207,6 +207,7 @@ Add Integration + {% if enable_webhooks %}Receive an HTTP callback when a check goes down.
Add Integration