From d897f2016fa86e88e1e4936a53ef14a64c803db6 Mon Sep 17 00:00:00 2001
From: Ngoc Khuat <qn.khuat@gmail.com>
Date: Fri, 30 Aug 2024 11:54:01 +0700
Subject: [PATCH] [notification] webhook for alert (#45201)

* [notification] New method: `channel/can-connect?` (#44955)

* [notification] Channel APIs (#45207)

* [notification] namespaced channel type (#45527)

* [Notification] Render alert for http channel (#45545)

* [notification] Add channel description (#45840)

* [notification] update API to enable http channels for alert (#45839)

* [Notification] Remove channel details for users without write perms (#46034)

* [Notification] Serdes channel (#46031)

* [Notification] Update http details schema (#45960)

* [Notification] Deactivate channels will delete PulseChannel (#46115)

* [Notification] audit log for channel create and update (#46113)

* [Notification] Disallow undefined key for http channel details (#46712)

* [Notification] Handle channel name conflicts (#46818)

* Webhooks Admin Section (#46194)

* [notification] Fix test pulse endpoint does not work properly for http channels (#46474) (#47050)

* [Notification] Fix unable to update multiple channels per type (#47111)

* [Notification] Record Task History when pulse sends channel message (#46218)

* Enabling Webhook Alerts (#47022)

* [Notification] fix cyclic deps (#47379)

* [notification] channel serdes spec (#47386)

Co-authored-by: Nick Fitzpatrick <nick@metabase.com>
---
 .clj-kondo/config.edn                         |   4 +
 .../actions/e2e-prepare-containers/action.yml |  18 +-
 .github/workflows/e2e-tests.yml               |   1 +
 dev/src/dev/render_png.clj                    |  59 +--
 .../helpers/e2e-notification-helpers.js       |  10 +
 e2e/support/helpers/index.js                  |   1 +
 .../scenarios/admin-2/settings.cy.spec.js     |  50 ++-
 e2e/test/scenarios/question/saved.cy.spec.js  | 110 ++++++
 .../sharing/alert/email-alert.cy.spec.js      |   2 +-
 .../advanced_permissions/common.clj           |   3 +-
 .../serialization/v2/models.clj               |   3 +-
 .../advanced_config/api/pulse_test.clj        |   3 +
 .../advanced_permissions/api/channel_test.clj |  66 ++++
 .../api/subscription_test.clj                 |  12 +-
 .../models/entity_id_test.clj                 |   3 +-
 .../serialization/v2/e2e_test.clj             |  25 +-
 .../AttributeMappingEditor.tsx                |   2 +-
 .../components/LoginAttributesWidget.tsx      |   3 +-
 .../src/metabase-types/api/mocks/channel.ts   |  24 ++
 .../src/metabase-types/api/notifications.ts   |  34 +-
 .../notifications/CreateWebhookModal.tsx      |  71 ++++
 .../notifications/EditWebhookModal.tsx        |  92 +++++
 .../notifications/NotificationSettings.tsx    | 135 +++++++
 .../settings/notifications/WebhookForm.tsx    | 306 ++++++++++++++++
 .../notifications/WebhookForm.unit.spec.tsx   | 216 +++++++++++
 .../admin/settings/notifications/utils.ts     |  52 +++
 .../settings/notifications/utils.unit.spec.ts |  99 +++++
 .../src/metabase/admin/settings/selectors.js  |   9 +-
 frontend/src/metabase/api/alert.ts            |   9 +
 frontend/src/metabase/api/channel.ts          |  70 ++++
 frontend/src/metabase/api/tags/constants.ts   |   1 +
 frontend/src/metabase/api/tags/utils.ts       |  16 +
 .../MappingEditor}/MappingEditor.tsx          |   9 +-
 .../core/components/MappingEditor/index.ts    |   1 +
 .../FormChipGroup/FormChipGroup.tsx           |  50 +++
 .../forms/components/FormChipGroup/index.ts   |   1 +
 .../FormKeyValueMapping.tsx                   |  47 +++
 .../components/FormKeyValueMapping/index.ts   |   1 +
 .../src/metabase/forms/components/index.ts    |   1 +
 .../hooks/use-action-button-label/index.ts    |   1 +
 .../use-action-button-label.ts                |  33 ++
 .../use-action-button-label.unit.spec.tsx     |  68 ++++
 frontend/src/metabase/lib/alert.js            |   2 +
 frontend/src/metabase/lib/pulse.ts            |   8 +-
 .../pulse/components/EmailChannelEdit.tsx     |  79 ++++
 .../pulse/components/PulseEditChannels.tsx    | 181 ++++-----
 .../pulse/components/SlackChannelEdit.tsx     |  64 ++++
 .../pulse/components/WebhookChannelEdit.tsx   | 119 ++++++
 .../ui/components/icons/Icon/icons/index.ts   |   6 +
 .../components/icons/Icon/icons/webhook.svg   |   3 +
 .../ui/components/inputs/Chip/Chip.styled.tsx |  36 ++
 .../ui/components/inputs/Chip/index.ts        |   3 +
 .../metabase/ui/components/inputs/index.ts    |   1 +
 frontend/src/metabase/ui/theme.ts             |   2 +
 .../migrations/001_update_migrations.yaml     | 107 ++++++
 src/metabase/api/channel.clj                  | 102 ++++++
 src/metabase/api/pulse.clj                    |   3 +-
 src/metabase/api/routes.clj                   |   2 +
 src/metabase/channel/core.clj                 |  26 +-
 src/metabase/channel/email.clj                |  10 +-
 src/metabase/channel/http.clj                 |  93 +++++
 src/metabase/channel/shared.clj               |  11 +
 src/metabase/channel/slack.clj                |   4 +-
 src/metabase/cmd/copy.clj                     |   3 +-
 src/metabase/core.clj                         |   3 +
 src/metabase/events/audit_log.clj             |   8 +
 src/metabase/models.clj                       |   2 +
 src/metabase/models/channel.clj               |  67 ++++
 src/metabase/models/permissions.clj           |   9 +-
 src/metabase/models/pulse.clj                 |  71 ++--
 src/metabase/models/pulse_channel.clj         |  37 +-
 src/metabase/models/task_history.clj          |  51 ++-
 src/metabase/pulse.clj                        | 113 ++++--
 src/metabase/pulse/render.clj                 |   8 +
 src/metabase/sync/util.clj                    |   4 +-
 src/metabase/task/persist_refresh.clj         |   2 +-
 src/metabase/util.cljc                        |  12 +-
 src/metabase/util/malli/schema.clj            |   6 +
 src/metabase/util/retry.clj                   |   7 +-
 test/metabase/api/alert_test.clj              |  52 ++-
 test/metabase/api/channel_test.clj            | 193 ++++++++++
 test/metabase/api/pulse_test.clj              | 152 ++++++++
 test/metabase/channel/email_test.clj          |   8 +-
 test/metabase/channel/http_test.clj           | 346 ++++++++++++++++++
 test/metabase/db/schema_migrations_test.clj   |  36 +-
 test/metabase/events/audit_log_test.clj       |  34 ++
 test/metabase/models/channel_test.clj         |  46 +++
 test/metabase/models/pulse_channel_test.clj   | 127 +++----
 test/metabase/models/pulse_test.clj           |  21 --
 test/metabase/models/task_history_test.clj    |  44 +++
 test/metabase/pulse/test_util.clj             |   4 +-
 test/metabase/pulse_test.clj                  | 269 +++++++++++---
 test/metabase/test/initialize/plugins.clj     |   4 +-
 test/metabase/test/mock/util.clj              |   3 +-
 test/metabase/test/util.clj                   |   4 +
 test/metabase/util/retry_test.clj             |   3 +-
 test/metabase/util_test.cljc                  |   7 +
 97 files changed, 3847 insertions(+), 492 deletions(-)
 create mode 100644 e2e/support/helpers/e2e-notification-helpers.js
 create mode 100644 enterprise/backend/test/metabase_enterprise/advanced_permissions/api/channel_test.clj
 create mode 100644 frontend/src/metabase-types/api/mocks/channel.ts
 create mode 100644 frontend/src/metabase/admin/settings/notifications/CreateWebhookModal.tsx
 create mode 100644 frontend/src/metabase/admin/settings/notifications/EditWebhookModal.tsx
 create mode 100644 frontend/src/metabase/admin/settings/notifications/NotificationSettings.tsx
 create mode 100644 frontend/src/metabase/admin/settings/notifications/WebhookForm.tsx
 create mode 100644 frontend/src/metabase/admin/settings/notifications/WebhookForm.unit.spec.tsx
 create mode 100644 frontend/src/metabase/admin/settings/notifications/utils.ts
 create mode 100644 frontend/src/metabase/admin/settings/notifications/utils.unit.spec.ts
 create mode 100644 frontend/src/metabase/api/channel.ts
 rename {enterprise/frontend/src/metabase-enterprise/sandboxes/components => frontend/src/metabase/core/components/MappingEditor}/MappingEditor.tsx (95%)
 create mode 100644 frontend/src/metabase/core/components/MappingEditor/index.ts
 create mode 100644 frontend/src/metabase/forms/components/FormChipGroup/FormChipGroup.tsx
 create mode 100644 frontend/src/metabase/forms/components/FormChipGroup/index.ts
 create mode 100644 frontend/src/metabase/forms/components/FormKeyValueMapping/FormKeyValueMapping.tsx
 create mode 100644 frontend/src/metabase/forms/components/FormKeyValueMapping/index.ts
 create mode 100644 frontend/src/metabase/hooks/use-action-button-label/index.ts
 create mode 100644 frontend/src/metabase/hooks/use-action-button-label/use-action-button-label.ts
 create mode 100644 frontend/src/metabase/hooks/use-action-button-label/use-action-button-label.unit.spec.tsx
 create mode 100644 frontend/src/metabase/pulse/components/EmailChannelEdit.tsx
 create mode 100644 frontend/src/metabase/pulse/components/SlackChannelEdit.tsx
 create mode 100644 frontend/src/metabase/pulse/components/WebhookChannelEdit.tsx
 create mode 100644 frontend/src/metabase/ui/components/icons/Icon/icons/webhook.svg
 create mode 100644 frontend/src/metabase/ui/components/inputs/Chip/Chip.styled.tsx
 create mode 100644 frontend/src/metabase/ui/components/inputs/Chip/index.ts
 create mode 100644 src/metabase/api/channel.clj
 create mode 100644 src/metabase/channel/http.clj
 create mode 100644 src/metabase/models/channel.clj
 create mode 100644 test/metabase/api/channel_test.clj
 create mode 100644 test/metabase/channel/http_test.clj
 create mode 100644 test/metabase/models/channel_test.clj

diff --git a/.clj-kondo/config.edn b/.clj-kondo/config.edn
index 0caacded868..0155cb3a7d5 100644
--- a/.clj-kondo/config.edn
+++ b/.clj-kondo/config.edn
@@ -125,6 +125,7 @@
      clojure.core.async/take!
      clojure.core.async/to-chan!
      clojure.core.async/to-chan!!
+     metabase.channel.core/send!
      metabase.driver.sql-jdbc.execute/execute-prepared-statement!
      metabase.pulse/send-pulse!
      metabase.query-processor.store/store-database!
@@ -605,6 +606,7 @@
     metabase.models.collection                                    collection
     metabase.models.collection.graph                              graph
     metabase.models.collection-permission-graph-revision          c-perm-revision
+    metabase.models.channel                                       models.channel
     metabase.models.dashboard-card                                dashboard-card
     metabase.models.database                                      database
     metabase.models.dependency                                    dependency
@@ -748,6 +750,8 @@
   metabase.api.search-test/do-test-users                                                clojure.core/let
   metabase.async.api-response-test/with-response                                        clojure.core/let
   metabase.dashboard-subscription-test/with-dashboard-sub-for-card                      clojure.core/let
+  metabase.channel.http-test/with-captured-http-requests                                clojure.core/fn
+  metabase.channel.http-test/with-server                                                clojure.core/let
   metabase.db.custom-migrations/define-migration                                        clj-kondo.lint-as/def-catch-all
   metabase.db.custom-migrations/define-reversible-migration                             clj-kondo.lint-as/def-catch-all
   metabase.db.data-migrations/defmigration                                              clojure.core/def
diff --git a/.github/actions/e2e-prepare-containers/action.yml b/.github/actions/e2e-prepare-containers/action.yml
index 3a7b1cc7952..ac82a79947b 100644
--- a/.github/actions/e2e-prepare-containers/action.yml
+++ b/.github/actions/e2e-prepare-containers/action.yml
@@ -10,24 +10,23 @@ inputs:
   maildev:
     description: Maildev container
     required: true
-    default: 'false'
+    default: false
   openldap:
     description: OpenLDAP container
     required: true
-    default: 'false'
+    default: false
   postgres:
     description: Postgres container
     required: true
-    default: 'false'
+    default: false
   mysql:
     description: MySQL container
     required: true
-    default: 'false'
+    default: false
   mongo:
     description: Mongo container
     required: true
-    default: 'false'
-
+    default: false
 
 runs:
   using: "composite"
@@ -84,4 +83,11 @@ runs:
           while ! nc -z localhost 27004; do sleep 1; done &&
           echo -e "${G}Mongo is up and running!"
         fi
+
+        if ${{ inputs.webhook }}; then
+          echo -e "${Y}Starting webhook test container..." &&
+          docker run -d -p 9080:8080/tcp tarampampam/webhook-tester serve --create-session 00000000-0000-0000-0000-000000000000 &&
+          while ! nc -z localhost 9080; do sleep 1; done &&
+          echo -e "${G}Webhook tester is up and running!"
+        fi
       shell: bash
diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml
index a73383c30d9..8fbf2aa3d74 100644
--- a/.github/workflows/e2e-tests.yml
+++ b/.github/workflows/e2e-tests.yml
@@ -145,6 +145,7 @@ jobs:
           postgres: ${{ matrix.name != 'mongo'}}
           mysql: ${{ matrix.name != 'mongo'}}
           mongo: ${{ matrix.name == 'mongo'}}
+          webhook: true
 
       - name: Retrieve uberjar artifact for ${{ matrix.edition }}
         uses: actions/download-artifact@v4
diff --git a/dev/src/dev/render_png.clj b/dev/src/dev/render_png.clj
index 3b8fdaf8392..cb65c56e59e 100644
--- a/dev/src/dev/render_png.clj
+++ b/dev/src/dev/render_png.clj
@@ -2,45 +2,49 @@
   "Improve feedback loop for dealing with png rendering code. Will create images using the rendering that underpins
   pulses and subscriptions and open those images without needing to send them to slack or email."
   (:require
-    [clojure.data.csv :as csv]
-    [clojure.java.io :as io]
-    [dev.util :as dev.u]
-    [hiccup.core :as hiccup]
-    [metabase.email.messages :as messages]
-    [metabase.models :refer [Card]]
-    [metabase.models.card :as card]
-    [metabase.pulse :as pulse]
-    [metabase.pulse.markdown :as markdown]
-    [metabase.pulse.render :as render]
-    [metabase.pulse.render.image-bundle :as img]
-    [metabase.pulse.render.png :as png]
-    [metabase.pulse.render.style :as style]
-    [metabase.query-processor :as qp]
-    [metabase.test :as mt]
-    [toucan2.core :as t2])
+   [clojure.data.csv :as csv]
+   [clojure.java.io :as io]
+   [dev.util :as dev.u]
+   [hiccup.core :as hiccup]
+   [metabase.email.messages :as messages]
+   [metabase.models :refer [Card]]
+   [metabase.models.card :as card]
+   [metabase.pulse :as pulse]
+   [metabase.pulse.markdown :as markdown]
+   [metabase.pulse.render :as render]
+   [metabase.pulse.render.image-bundle :as img]
+   [metabase.pulse.render.png :as png]
+   [metabase.pulse.render.style :as style]
+   [metabase.query-processor :as qp]
+   [metabase.test :as mt]
+   [toucan2.core :as t2])
   (:import (java.io File)))
 
 (set! *warn-on-reflection* true)
 
-;; taken from https://github.com/aysylu/loom/blob/master/src/loom/io.clj
+(defn open-png-bytes
+  "Given a byte array, writes it to a temporary file, then opens that file in the default application for png files."
+  [bytes]
+  (let [tmp-file (File/createTempFile "card-png" ".png")]
+    (with-open [w (java.io.FileOutputStream. tmp-file)]
+      (.write w ^bytes bytes))
+    (.deleteOnExit tmp-file)
+    (dev.u/os-open tmp-file)))
+
 (defn render-card-to-png
   "Given a card ID, renders the card to a png and opens it. Be aware that the png rendered on a dev machine may not
   match what's rendered on another system, like a docker container."
   [card-id]
   (let [{:keys [dataset_query result_metadata], card-type :type, :as card} (t2/select-one card/Card :id card-id)
         query-results (qp/process-query
-                        (cond-> dataset_query
-                          (= card-type :model)
-                          (assoc-in [:info :metadata/model-metadata] result_metadata)))
+                       (cond-> dataset_query
+                         (= card-type :model)
+                         (assoc-in [:info :metadata/model-metadata] result_metadata)))
         png-bytes     (render/render-pulse-card-to-png (pulse/defaulted-timezone card)
                                                        card
                                                        query-results
-                                                       1000)
-        tmp-file      (File/createTempFile "card-png" ".png")]
-    (with-open [w (java.io.FileOutputStream. tmp-file)]
-      (.write w ^bytes png-bytes))
-    (.deleteOnExit tmp-file)
-    (dev.u/os-open tmp-file)))
+                                                       1000)]
+    (open-png-bytes png-bytes)))
 
 (defn render-pulse-card
   "Render a pulse card as a data structure"
@@ -144,7 +148,7 @@
        (cellfn nil)
        (cellfn
         [:div {:style (style/style {:font-family             "Lato"
-                                    :font-size               "13px" #_ "0.875em"
+                                    :font-size               "13px" #_"0.875em"
                                     :font-weight             "400"
                                     :font-style              "normal"
                                     :color                   "#4c5773"
@@ -172,7 +176,6 @@
   [dashboard-id]
   (hiccup/html (render-dashboard-to-hiccup dashboard-id)))
 
-
 (defn render-dashboard-to-html-and-open
   "Given a dashboard ID, renders all of the dashcards to an html file and opens it."
   [dashboard-id]
diff --git a/e2e/support/helpers/e2e-notification-helpers.js b/e2e/support/helpers/e2e-notification-helpers.js
new file mode 100644
index 00000000000..4ca7ceb109c
--- /dev/null
+++ b/e2e/support/helpers/e2e-notification-helpers.js
@@ -0,0 +1,10 @@
+export const getAlertChannel = name =>
+  cy.findByRole("listitem", {
+    name,
+  });
+
+export const WEBHOOK_TEST_SESSION_ID = "00000000-0000-0000-0000-000000000000";
+export const WEBHOOK_TEST_HOST = "http://127.0.0.1:9080";
+
+export const WEBHOOK_TEST_URL = `${WEBHOOK_TEST_HOST}/${WEBHOOK_TEST_SESSION_ID}`;
+export const WEBHOOK_TEST_DASHBOARD = `${WEBHOOK_TEST_HOST}/#/${WEBHOOK_TEST_SESSION_ID}`;
diff --git a/e2e/support/helpers/index.js b/e2e/support/helpers/index.js
index 5bc9dbd5c52..6894d06aabe 100644
--- a/e2e/support/helpers/index.js
+++ b/e2e/support/helpers/index.js
@@ -21,6 +21,7 @@ export * from "./e2e-misc-helpers";
 export * from "./e2e-mock-app-settings-helpers";
 export * from "./e2e-models-metadata-helpers";
 export * from "./e2e-notebook-helpers";
+export * from "./e2e-notification-helpers";
 export * from "./e2e-permissions-helpers";
 export * from "./e2e-qa-databases-helpers";
 export * from "./e2e-relative-date-picker-helpers";
diff --git a/e2e/test/scenarios/admin-2/settings.cy.spec.js b/e2e/test/scenarios/admin-2/settings.cy.spec.js
index 2de559d3659..92195ce1bba 100644
--- a/e2e/test/scenarios/admin-2/settings.cy.spec.js
+++ b/e2e/test/scenarios/admin-2/settings.cy.spec.js
@@ -6,6 +6,7 @@ import {
 import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
 import { ORDERS_QUESTION_ID } from "e2e/support/cypress_sample_instance_data";
 import {
+  WEBHOOK_TEST_URL,
   describeEE,
   describeWithSnowplow,
   echartsContainer,
@@ -343,7 +344,7 @@ describeWithSnowplow("scenarios > admin > settings", () => {
 
   describe(" > slack settings", () => {
     it("should present the form and display errors", () => {
-      cy.visit("/admin/settings/slack");
+      cy.visit("/admin/settings/notifications/slack");
 
       // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
       cy.findByText("Metabase on Slack");
@@ -1135,6 +1136,53 @@ describe("scenarios > admin > settings > map settings", () => {
   });
 });
 
+// Ensure the webhook tester docker container is running
+// docker run -p 9080:8080/tcp tarampampam/webhook-tester serve --create-session 00000000-0000-0000-0000-000000000000
+describe("notifications", { tags: "@external" }, () => {
+  beforeEach(() => {
+    restore();
+    cy.signInAsAdmin();
+  });
+  it("Should allow you to create and edit Notifications", () => {
+    cy.visit("/admin/settings/notifications");
+
+    cy.findByRole("heading", { name: "Add a webhook" }).click();
+
+    modal().within(() => {
+      cy.findByRole("heading", { name: "New webhook destination" }).should(
+        "exist",
+      );
+
+      cy.findByLabelText("Webhook URL").type(`${WEBHOOK_TEST_URL}/404`);
+      cy.findByLabelText("Give it a name").type("Awesome Hook");
+      cy.findByLabelText("Description").type("The best hook ever");
+      cy.button("Create destination").click();
+
+      cy.findByText("Unable to connect channel").should("exist");
+      cy.findByLabelText("Webhook URL").clear().type(WEBHOOK_TEST_URL);
+      cy.button("Create destination").click();
+    });
+
+    cy.findByRole("button", { name: /Add another/ }).should("exist");
+
+    cy.findByRole("heading", { name: "Awesome Hook" }).click();
+
+    modal().within(() => {
+      cy.findByRole("heading", { name: "Edit this webhook" }).should("exist");
+      cy.findByLabelText("Give it a name").clear().type("Updated Hook");
+      cy.button("Save changes").click();
+    });
+
+    cy.findByRole("heading", { name: "Updated Hook" }).click();
+
+    modal()
+      .button(/Delete this destination/)
+      .click();
+
+    cy.findByRole("heading", { name: "Add a webhook" }).should("exist");
+  });
+});
+
 describe("admin > upload settings", () => {
   describe("scenarios > admin > uploads (OSS)", { tags: "@OSS" }, () => {
     beforeEach(() => {
diff --git a/e2e/test/scenarios/question/saved.cy.spec.js b/e2e/test/scenarios/question/saved.cy.spec.js
index dac196e0efe..f1f5c73a105 100644
--- a/e2e/test/scenarios/question/saved.cy.spec.js
+++ b/e2e/test/scenarios/question/saved.cy.spec.js
@@ -1,13 +1,19 @@
 import {
+  ORDERS_COUNT_QUESTION_ID,
   ORDERS_QUESTION_ID,
   SECOND_COLLECTION_ID,
 } from "e2e/support/cypress_sample_instance_data";
 import {
+  WEBHOOK_TEST_DASHBOARD,
+  WEBHOOK_TEST_HOST,
+  WEBHOOK_TEST_SESSION_ID,
+  WEBHOOK_TEST_URL,
   addSummaryGroupingField,
   appBar,
   collectionOnTheGoModal,
   entityPickerModal,
   entityPickerModalTab,
+  getAlertChannel,
   modal,
   openNotebook,
   openOrdersTable,
@@ -326,3 +332,107 @@ describe("scenarios > question > saved", () => {
     });
   });
 });
+
+//http://127.0.0.1:9080/api/session/00000000-0000-0000-0000-000000000000/requests
+
+// Ensure the webhook tester docker container is running
+// docker run -p 9080:8080/tcp tarampampam/webhook-tester serve --create-session 00000000-0000-0000-0000-000000000000
+describe(
+  "scenarios > question > saved > alerts",
+  { tags: ["@external"] },
+
+  () => {
+    const firstWebhookName = "E2E Test Webhook";
+    const secondWebhookName = "Toucan Hook";
+
+    beforeEach(() => {
+      restore();
+      cy.signInAsAdmin();
+
+      cy.request("POST", "/api/channel", {
+        name: firstWebhookName,
+        description: "All aboard the Metaboat",
+        type: "channel/http",
+        details: {
+          url: WEBHOOK_TEST_URL,
+          "auth-method": "none",
+          "fe-form-type": "none",
+        },
+      });
+
+      cy.request("POST", "/api/channel", {
+        name: secondWebhookName,
+        description: "Quack!",
+        type: "channel/http",
+        details: {
+          url: WEBHOOK_TEST_URL,
+          "auth-method": "none",
+          "fe-form-type": "none",
+        },
+      });
+
+      cy.request(
+        "DELETE",
+        `${WEBHOOK_TEST_HOST}/api/session/${WEBHOOK_TEST_SESSION_ID}/requests`,
+        { failOnStatusCode: false },
+      );
+    });
+
+    it("should allow you to enable a webhook alert", () => {
+      visitQuestion(ORDERS_COUNT_QUESTION_ID);
+      cy.findByTestId("sharing-menu-button").click();
+      popover().findByText("Create alert").click();
+      modal().button("Set up an alert").click();
+      modal().within(() => {
+        getAlertChannel(secondWebhookName).scrollIntoView();
+        getAlertChannel(secondWebhookName)
+          .findByRole("checkbox")
+          .click({ force: true });
+        cy.button("Done").click();
+      });
+      cy.findByTestId("sharing-menu-button").click();
+      popover().findByText("Edit alerts").click();
+      popover().within(() => {
+        cy.findByText("You set up an alert").should("exist");
+        cy.findByText("Edit").click();
+      });
+
+      modal().within(() => {
+        getAlertChannel(secondWebhookName).scrollIntoView();
+        getAlertChannel(secondWebhookName)
+          .findByRole("checkbox")
+          .should("be.checked");
+      });
+    });
+
+    it("should allow you to test a webhook", () => {
+      visitQuestion(ORDERS_COUNT_QUESTION_ID);
+      cy.findByTestId("sharing-menu-button").click();
+      popover().findByText("Create alert").click();
+      modal().button("Set up an alert").click();
+      modal().within(() => {
+        getAlertChannel(firstWebhookName).scrollIntoView();
+
+        getAlertChannel(firstWebhookName)
+          .findByRole("checkbox")
+          .click({ force: true });
+
+        getAlertChannel(firstWebhookName).button("Send a test").click();
+      });
+
+      cy.visit(WEBHOOK_TEST_DASHBOARD);
+
+      cy.findByRole("heading", { name: /Requests 1/ }).should("exist");
+
+      cy.request(
+        `${WEBHOOK_TEST_HOST}/api/session/${WEBHOOK_TEST_SESSION_ID}/requests`,
+      ).then(({ body }) => {
+        const payload = cy.wrap(atob(body[0].content_base64));
+
+        payload
+          .should("have.string", "alert_creator_name")
+          .and("have.string", "Bobby Tables");
+      });
+    });
+  },
+);
diff --git a/e2e/test/scenarios/sharing/alert/email-alert.cy.spec.js b/e2e/test/scenarios/sharing/alert/email-alert.cy.spec.js
index d4e97426853..5cffe5dbb0b 100644
--- a/e2e/test/scenarios/sharing/alert/email-alert.cy.spec.js
+++ b/e2e/test/scenarios/sharing/alert/email-alert.cy.spec.js
@@ -146,7 +146,7 @@ function openAlertForQuestion(id) {
 }
 
 function toggleChannel(channel) {
-  cy.findByText(channel).parent().find("input").click();
+  cy.findByText(channel).parent().find("input").click({ force: true });
 }
 
 function saveAlert() {
diff --git a/enterprise/backend/src/metabase_enterprise/advanced_permissions/common.clj b/enterprise/backend/src/metabase_enterprise/advanced_permissions/common.clj
index 856fdaa81a3..2c2d29039db 100644
--- a/enterprise/backend/src/metabase_enterprise/advanced_permissions/common.clj
+++ b/enterprise/backend/src/metabase_enterprise/advanced_permissions/common.clj
@@ -71,8 +71,9 @@
             :can_access_db_details   (data-perms/user-has-any-perms-of-type? api/*current-user-id* :perms/manage-database)
             :is_group_manager        api/*is-group-manager?*})))
 
-(defn current-user-has-application-permissions?
+(defenterprise current-user-has-application-permissions?
   "Check if `*current-user*` has permissions for a application permissions of type `perm-type`."
+  :feature :advanced-permissions
   [perm-type]
   (or api/*is-superuser?*
       (perms/set-has-application-permission-of-type? @api/*current-user-permissions-set* perm-type)))
diff --git a/enterprise/backend/src/metabase_enterprise/serialization/v2/models.clj b/enterprise/backend/src/metabase_enterprise/serialization/v2/models.clj
index 579e23f063d..c61791cad6a 100644
--- a/enterprise/backend/src/metabase_enterprise/serialization/v2/models.clj
+++ b/enterprise/backend/src/metabase_enterprise/serialization/v2/models.clj
@@ -5,7 +5,8 @@
   ["Database"
    "Field"
    "Segment"
-   "Table"])
+   "Table"
+   "Channel"])
 
 (def content
   "Content model types"
diff --git a/enterprise/backend/test/metabase_enterprise/advanced_config/api/pulse_test.clj b/enterprise/backend/test/metabase_enterprise/advanced_config/api/pulse_test.clj
index f4aa4523d49..7241ff27012 100644
--- a/enterprise/backend/test/metabase_enterprise/advanced_config/api/pulse_test.clj
+++ b/enterprise/backend/test/metabase_enterprise/advanced_config/api/pulse_test.clj
@@ -3,9 +3,12 @@
    [clojure.test :refer :all]
    [metabase.models :refer [Card]]
    [metabase.test :as mt]
+   [metabase.test.fixtures :as fixtures]
    [metabase.util :as u]
    [toucan2.tools.with-temp :as t2.with-temp]))
 
+(use-fixtures :once (fixtures/initialize :plugins))
+
 (deftest test-pulse-endpoint-should-respect-email-domain-allow-list-test
   (testing "POST /api/pulse/test"
     (t2.with-temp/with-temp [Card card {:name          "Test card"
diff --git a/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/channel_test.clj b/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/channel_test.clj
new file mode 100644
index 00000000000..9d78cd5cb38
--- /dev/null
+++ b/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/channel_test.clj
@@ -0,0 +1,66 @@
+(ns metabase-enterprise.advanced-permissions.api.channel-test
+  (:require
+   [clojure.test :refer :all]
+   [metabase.api.channel-test :as api.channel-test]
+   [metabase.models.permissions :as perms]
+   [metabase.test :as mt]))
+
+(comment
+ ;; to register the :metabase-test channel implementation
+  api.channel-test/keepme)
+
+(deftest channel-api-test
+  (testing "/api/channel"
+    (mt/with-model-cleanup [:model/Channel]
+      (mt/with-user-in-groups
+        [group {:name "New Group"}
+         user  [group]]
+        (letfn [(update-channel [user status]
+                  (testing (format "set channel setting with %s user" (mt/user-descriptor user))
+                    (mt/with-temp [:model/Channel {id :id} {:type "channel/metabase-test"
+                                                            :details {:return-type  "return-value"
+                                                                      :return-value true}}]
+                      (mt/user-http-request user :put status (format "channel/%d" id) {:name (mt/random-name)}))))
+                (create-channel [user status]
+                  (testing (format "create channel setting with %s user" (mt/user-descriptor user))
+                    (mt/user-http-request user :post status "channel" {:name (mt/random-name)
+                                                                       :type "channel/metabase-test"
+                                                                       :details {:return-type  "return-value"
+                                                                                 :return-value true}})))
+                (include-details [user include-details?]
+                  (mt/with-temp [:model/Channel {id :id} {:type "channel/metabase-test"
+                                                          :details {:return-type  "return-value"
+                                                                    :return-value true}}]
+                    (testing (format "GET /api/channel/:id with %s user" (mt/user-descriptor user))
+                      (is (= include-details? (contains? (mt/user-http-request user :get 200 (str "channel/" id)) :details))))
+
+                    (testing (format "GET /api/channel with %s user" (mt/user-descriptor user))
+                      (is (every? #(= % include-details?) (map #(contains? % :details) (mt/user-http-request user :get 200 "channel/")))))))]
+
+          (testing "if `advanced-permissions` is disabled, require admins"
+            (mt/with-premium-features #{}
+              (create-channel user 403)
+              (update-channel user 403)
+              (create-channel :crowberto 200)
+              (update-channel :crowberto 200)
+              (include-details :crowberto true)
+              (include-details user false)))
+
+          (testing "if `advanced-permissions` is enabled"
+            (mt/with-premium-features #{:advanced-permissions}
+              (testing "still fail if user's group doesn't have `setting` permission"
+                (create-channel user 403)
+                (update-channel user 403)
+                (create-channel :crowberto 200)
+                (update-channel :crowberto 200)
+                (include-details :crowberto true)
+                (include-details user false))
+
+              (testing "succeed if user's group has `setting` permission"
+                (perms/grant-application-permissions! group :setting)
+                (create-channel user 200)
+                (update-channel user 200)
+                (create-channel :crowberto 200)
+                (update-channel user 200)
+                (include-details :crowberto true)
+                (include-details user true)))))))))
diff --git a/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/subscription_test.clj b/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/subscription_test.clj
index 4b1d9c0963d..551b7dcd7d4 100644
--- a/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/subscription_test.clj
+++ b/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/subscription_test.clj
@@ -80,9 +80,9 @@
          user  [group]]
         (mt/with-temp [Card {card-id :id} {}]
           (letfn [(add-pulse-recipient [req-user status]
-                    (pulse-test/with-pulse-for-card [the-pulse {:card    card-id
-                                                                :pulse   {:creator_id (u/the-id user)}
-                                                                :channel :email}]
+                    (pulse-test/with-pulse-for-card [the-pulse {:card          card-id
+                                                                :pulse         {:creator_id (u/the-id user)}
+                                                                :pulse-channel :email}]
                       (let [the-pulse   (pulse/retrieve-pulse (:id the-pulse))
                             channel     (api.alert/email-channel the-pulse)
                             new-channel (assoc channel :recipients (conj (:recipients channel) (mt/fetch-user :lucky)))
@@ -91,9 +91,9 @@
                           (mt/user-http-request req-user :put status (format "pulse/%d" (:id the-pulse)) new-pulse)))))
 
                   (remove-pulse-recipient [req-user status]
-                    (pulse-test/with-pulse-for-card [the-pulse {:card    card-id
-                                                                :pulse   {:creator_id (u/the-id user)}
-                                                                :channel :email}]
+                    (pulse-test/with-pulse-for-card [the-pulse {:card          card-id
+                                                                :pulse         {:creator_id (u/the-id user)}
+                                                                :pulse-channel :email}]
                       ;; manually add another user as recipient
                       (t2.with-temp/with-temp [PulseChannelRecipient _ {:user_id (:id user)
                                                                         :pulse_channel_id
diff --git a/enterprise/backend/test/metabase_enterprise/models/entity_id_test.clj b/enterprise/backend/test/metabase_enterprise/models/entity_id_test.clj
index e9545a3b68c..c1ca5df0c3d 100644
--- a/enterprise/backend/test/metabase_enterprise/models/entity_id_test.clj
+++ b/enterprise/backend/test/metabase_enterprise/models/entity_id_test.clj
@@ -20,7 +20,8 @@
 
 (def ^:private entities-external-name
   "Entities with external names, so they don't need a generated entity_id."
-  #{;; Databases have external names based on their URLs; tables are nested under databases; fields under tables.
+  #{:model/Channel
+    ;; Databases have external names based on their URLs; tables are nested under databases; fields under tables.
     :model/Database
     :model/Table
     :model/Field
diff --git a/enterprise/backend/test/metabase_enterprise/serialization/v2/e2e_test.clj b/enterprise/backend/test/metabase_enterprise/serialization/v2/e2e_test.clj
index 2d665fa8123..ba513d48b4a 100644
--- a/enterprise/backend/test/metabase_enterprise/serialization/v2/e2e_test.clj
+++ b/enterprise/backend/test/metabase_enterprise/serialization/v2/e2e_test.clj
@@ -638,7 +638,7 @@
     (ts/with-random-dump-dir [dump-dir "serdesv2-"]
       (ts/with-dbs [source-db dest-db]
         (ts/with-db source-db
-         ;; preparation
+          ;; preparation
           (t2.with-temp/with-temp
             [Dashboard           {dashboard-id :id
                                   dashboard-eid :entity_id} {:name "Dashboard with tab"}
@@ -667,7 +667,7 @@
 
             (testing "ingest and load"
               (ts/with-db dest-db
-               ;; ingest
+                ;; ingest
                 (testing "doing ingestion"
                   (is (serdes/with-cache (serdes.load/load-metabase! (ingest/ingest-yaml dump-dir)))
                       "successful"))
@@ -832,3 +832,24 @@
               (testing ".yaml files not containing valid yaml are just logged and do not break ingestion process"
                 (is (=? [{:level :error, :e Throwable, :message "Error reading file unreadable.yaml"}]
                         (logs)))))))))))
+
+(deftest channel-test
+  (mt/test-helpers-set-global-values!
+    (ts/with-random-dump-dir [dump-dir "serdesv2-"]
+      (ts/with-dbs [source-db dest-db]
+        (ts/with-db source-db
+          (mt/with-temp
+            [:model/Channel _ {:name "My HTTP channel"
+                               :type :channel/http
+                               :details {:url         "http://example.com"
+                                         :auth-method :none}}]
+            (storage/store! (seq (serdes/with-cache (into [] (extract/extract {})))) dump-dir)
+            (ts/with-db dest-db
+              (testing "doing ingestion"
+                (is (serdes/with-cache (serdes.load/load-metabase! (ingest/ingest-yaml dump-dir)))
+                    "successful")
+                (is (=? {:name    "My HTTP channel"
+                         :type    :channel/http
+                         :details {:url         "http://example.com"
+                                   :auth-method "none"}}
+                        (t2/select-one :model/Channel :name "My HTTP channel")))))))))))
diff --git a/enterprise/frontend/src/metabase-enterprise/sandboxes/components/AttributeMappingEditor/AttributeMappingEditor.tsx b/enterprise/frontend/src/metabase-enterprise/sandboxes/components/AttributeMappingEditor/AttributeMappingEditor.tsx
index f689bbf159e..6d716eb09d6 100644
--- a/enterprise/frontend/src/metabase-enterprise/sandboxes/components/AttributeMappingEditor/AttributeMappingEditor.tsx
+++ b/enterprise/frontend/src/metabase-enterprise/sandboxes/components/AttributeMappingEditor/AttributeMappingEditor.tsx
@@ -1,6 +1,7 @@
 import cx from "classnames";
 import { t } from "ttag";
 
+import { MappingEditor } from "metabase/core/components/MappingEditor";
 import type { SelectChangeEvent } from "metabase/core/components/Select";
 import Select, { Option } from "metabase/core/components/Select";
 import Tooltip from "metabase/core/components/Tooltip";
@@ -15,7 +16,6 @@ import type {
 } from "metabase-types/api";
 
 import QuestionParameterTargetWidget from "../../containers/QuestionParameterTargetWidget";
-import { MappingEditor } from "../MappingEditor";
 
 interface AttributeMappingEditorProps {
   value: any;
diff --git a/enterprise/frontend/src/metabase-enterprise/sandboxes/components/LoginAttributesWidget.tsx b/enterprise/frontend/src/metabase-enterprise/sandboxes/components/LoginAttributesWidget.tsx
index ceb135b118b..b50f1560dbe 100644
--- a/enterprise/frontend/src/metabase-enterprise/sandboxes/components/LoginAttributesWidget.tsx
+++ b/enterprise/frontend/src/metabase-enterprise/sandboxes/components/LoginAttributesWidget.tsx
@@ -3,8 +3,7 @@ import type { HTMLAttributes } from "react";
 import { t } from "ttag";
 
 import FormField from "metabase/core/components/FormField";
-
-import { MappingEditor } from "./MappingEditor";
+import { MappingEditor } from "metabase/core/components/MappingEditor";
 
 interface Props extends HTMLAttributes<HTMLDivElement> {
   name: string;
diff --git a/frontend/src/metabase-types/api/mocks/channel.ts b/frontend/src/metabase-types/api/mocks/channel.ts
new file mode 100644
index 00000000000..b0fa5aabee3
--- /dev/null
+++ b/frontend/src/metabase-types/api/mocks/channel.ts
@@ -0,0 +1,24 @@
+import type { ChannelDetails, NotificationChannel } from "../notifications";
+
+export const createMockChannelDetails = (
+  opts: Partial<ChannelDetails>,
+): ChannelDetails => ({
+  url: "http://google.com",
+  "auth-method": "none",
+  "fe-form-type": "none",
+  ...opts,
+});
+
+export const createMockChannel = (
+  opts: Partial<NotificationChannel>,
+): NotificationChannel => ({
+  id: 1,
+  name: "Awesome Hook",
+  description: "A great hook",
+  active: true,
+  created_at: "2024-01-01T00:00:00Z",
+  updated_at: "2024-01-01T00:00:00Z",
+  details: createMockChannelDetails({}),
+  type: "channel/http",
+  ...opts,
+});
diff --git a/frontend/src/metabase-types/api/notifications.ts b/frontend/src/metabase-types/api/notifications.ts
index 66083327854..07aaf9aa7e9 100644
--- a/frontend/src/metabase-types/api/notifications.ts
+++ b/frontend/src/metabase-types/api/notifications.ts
@@ -16,6 +16,7 @@ export type Channel = {
   details: Record<string, string>;
   enabled?: boolean;
   recipients?: User[];
+  channel_id?: number;
 } & Pick<
   ScheduleSettings,
   "schedule_day" | "schedule_type" | "schedule_hour" | "schedule_frame"
@@ -58,17 +59,44 @@ export type PulseParameter = {
   value?: string;
 };
 
+export type ChannelDetails = {
+  url: string;
+  "auth-method": NotificationAuthMethods;
+  "auth-info"?: Record<string, string>;
+  "fe-form-type": NotificationAuthType;
+};
+
+export type NotificationAuthMethods =
+  | "none"
+  | "header"
+  | "query-param"
+  | "request-body";
+
+export type NotificationAuthType = "none" | "basic" | "bearer" | "api-key";
+
+export type NotificationChannel<Details = ChannelDetails> = {
+  active: boolean;
+  created_at: string;
+  details: Details;
+  type: "channel/http";
+  updated_at: string;
+  id: number;
+  name: string;
+  description: string;
+};
+
 export type SlackChannelSpec = ChannelSpec & {
   fields: ChannelField[];
 };
 
-type EmailChannelSpec = ChannelSpec & {
+export type EmailChannelSpec = ChannelSpec & {
   recipients: ChannelSpecRecipients;
 };
 export interface ChannelApiResponse {
   channels: {
-    email: SlackChannelSpec;
-    slack: EmailChannelSpec;
+    email: EmailChannelSpec;
+    slack: SlackChannelSpec;
+    http: ChannelSpec;
   };
 }
 
diff --git a/frontend/src/metabase/admin/settings/notifications/CreateWebhookModal.tsx b/frontend/src/metabase/admin/settings/notifications/CreateWebhookModal.tsx
new file mode 100644
index 00000000000..5c84ad5a65d
--- /dev/null
+++ b/frontend/src/metabase/admin/settings/notifications/CreateWebhookModal.tsx
@@ -0,0 +1,71 @@
+import { t } from "ttag";
+
+import { useCreateChannelMutation } from "metabase/api/channel";
+import { Modal } from "metabase/ui";
+
+import {
+  WebhookForm,
+  type WebhookFormProps,
+  handleFieldError,
+} from "./WebhookForm";
+import { buildAuthInfo } from "./utils";
+
+interface CreateWebhookModalProps {
+  isOpen: boolean;
+  onClose: () => void;
+}
+
+const initialValues = {
+  url: "",
+  name: "",
+  description: "",
+  "auth-method": "none" as const,
+  "fe-form-type": "none" as const,
+};
+
+export const CreateWebhookModal = ({
+  isOpen,
+  onClose,
+}: CreateWebhookModalProps) => {
+  const [createChannel] = useCreateChannelMutation();
+  const handleSubmit = async (vals: WebhookFormProps) => {
+    return createChannel({
+      name: vals.name,
+      type: "channel/http",
+      description: vals.description,
+      details: {
+        url: vals.url,
+        "fe-form-type": vals["fe-form-type"],
+        "auth-method": vals["auth-method"],
+        "auth-info": buildAuthInfo(vals),
+      },
+    })
+      .unwrap()
+      .then(() => {
+        onClose();
+      })
+      .catch(e => {
+        handleFieldError(e);
+        throw e;
+      });
+  };
+
+  return (
+    <Modal.Root opened={isOpen} onClose={onClose} size="36rem">
+      <Modal.Overlay />
+      <Modal.Content>
+        <Modal.Header p="2.5rem" mb="1.5rem">
+          <Modal.Title>{t`New webhook destination`}</Modal.Title>
+          <Modal.CloseButton />
+        </Modal.Header>
+        <Modal.Body p="2.5rem">
+          <WebhookForm
+            onSubmit={handleSubmit}
+            onCancel={onClose}
+            initialValues={initialValues}
+          />
+        </Modal.Body>
+      </Modal.Content>
+    </Modal.Root>
+  );
+};
diff --git a/frontend/src/metabase/admin/settings/notifications/EditWebhookModal.tsx b/frontend/src/metabase/admin/settings/notifications/EditWebhookModal.tsx
new file mode 100644
index 00000000000..33209be2916
--- /dev/null
+++ b/frontend/src/metabase/admin/settings/notifications/EditWebhookModal.tsx
@@ -0,0 +1,92 @@
+import { useMemo } from "react";
+import { t } from "ttag";
+
+import {
+  useDeleteChannelMutation,
+  useEditChannelMutation,
+} from "metabase/api/channel";
+import { Modal } from "metabase/ui";
+import type { NotificationChannel } from "metabase-types/api";
+
+import {
+  WebhookForm,
+  type WebhookFormProps,
+  handleFieldError,
+} from "./WebhookForm";
+import { buildAuthInfo, channelToForm } from "./utils";
+
+interface CreateWebhookModalProps {
+  isOpen: boolean;
+  onClose: () => void;
+  channel: NotificationChannel;
+}
+
+export const EditWebhookModal = ({
+  isOpen,
+  onClose,
+  channel,
+}: CreateWebhookModalProps) => {
+  const [editChannel] = useEditChannelMutation();
+  const [deleteChannel] = useDeleteChannelMutation();
+
+  const handleSumbit = async (vals: WebhookFormProps) => {
+    return editChannel({
+      id: channel.id,
+      name: vals.name,
+      description: vals.description,
+      details: {
+        url: vals.url,
+        "fe-form-type": vals["fe-form-type"],
+        "auth-method": vals["auth-method"],
+        "auth-info": buildAuthInfo(vals),
+      },
+    })
+      .unwrap()
+      .then(() => {
+        onClose();
+      })
+      .catch(e => {
+        handleFieldError(e);
+      });
+  };
+
+  const handleDelete = async () => {
+    await deleteChannel(channel.id).unwrap();
+
+    onClose();
+  };
+
+  const initialValues = useMemo(
+    () => ({
+      url: channel.details.url,
+      name: channel.name,
+      description: channel.description,
+      "auth-method": channel.details["auth-method"],
+      // "auth-info": channel.details["auth-info"] || { "": "" },
+      "fe-form-type": channel.details["fe-form-type"],
+      ...channelToForm(channel),
+    }),
+    [channel],
+  );
+
+  return (
+    <Modal.Root opened={isOpen} onClose={onClose} size="36rem">
+      <Modal.Overlay />
+      <Modal.Content>
+        <Modal.Header p="2.5rem" mb="1.5rem">
+          <Modal.Title>{t`Edit this webhook`}</Modal.Title>
+          <Modal.CloseButton />
+        </Modal.Header>
+        <Modal.Body p="2.5rem">
+          <WebhookForm
+            onSubmit={handleSumbit}
+            onCancel={onClose}
+            onDelete={handleDelete}
+            initialValues={initialValues}
+            submitLabel={t`Save changes`}
+          />
+        </Modal.Body>
+      </Modal.Content>
+    </Modal.Root>
+  );
+};
diff --git a/frontend/src/metabase/admin/settings/notifications/NotificationSettings.tsx b/frontend/src/metabase/admin/settings/notifications/NotificationSettings.tsx
new file mode 100644
index 00000000000..6c67648507a
--- /dev/null
+++ b/frontend/src/metabase/admin/settings/notifications/NotificationSettings.tsx
@@ -0,0 +1,135 @@
+import { useState } from "react";
+import { Link } from "react-router";
+import { c, t } from "ttag";
+
+import { useListChannelsQuery } from "metabase/api/channel";
+import {
+  Box,
+  Button,
+  Flex,
+  Icon,
+  type IconName,
+  Paper,
+  Stack,
+  Text,
+  Title,
+} from "metabase/ui";
+import type { NotificationChannel } from "metabase-types/api";
+
+import { CreateWebhookModal } from "./CreateWebhookModal";
+import { EditWebhookModal } from "./EditWebhookModal";
+
+type NotificationModals = null | "create" | "edit";
+
+export const NotificationSettings = () => {
+  const [webhookModal, setWebhookModal] = useState<NotificationModals>(null);
+  const [currentChannel, setCurrentChannel] = useState<NotificationChannel>();
+
+  const { data: channels } = useListChannelsQuery();
+
+  const hasChannels = channels && channels.length > 0;
+
+  return (
+    <>
+      <Box w="47rem">
+        <Title mb="1.5rem">{t`Slack`}</Title>
+        <Link to="/admin/settings/notifications/slack">
+          <Paper shadow="0" withBorder p="lg" w="47rem" mb="2.5rem">
+            <Flex gap="0.5rem" align="center" mb="0.5rem">
+              <Icon name="slack_colorized" />
+              <Title order={2}>{t`Connect to Slack`}</Title>
+            </Flex>
+            <Text>
+              {t`If your team uses Slack, you can send dashboard subscriptions and
+            alerts there`}
+            </Text>
+          </Paper>
+        </Link>
+
+        <Flex justify="space-between" align="center" mb="1.5rem">
+          <Title>{t`Webhooks for Alerts`}</Title>{" "}
+          {hasChannels && (
+            <Button
+              variant="subtle"
+              compact
+              leftIcon={<Icon name="add" />}
+              onClick={() => setWebhookModal("create")}
+            >{c("Short for 'Add another Webhook'").t`Add another`}</Button>
+          )}
+        </Flex>
+        {hasChannels ? (
+          <Stack>
+            {channels?.map(c => (
+              <ChannelBox
+                key={`channel-${c.id}`}
+                title={c.name}
+                description={c.description}
+                onClick={() => {
+                  setWebhookModal("edit");
+                  setCurrentChannel(c);
+                }}
+                icon="webhook"
+              />
+            ))}
+          </Stack>
+        ) : (
+          <ChannelBox
+            title={t`Add a webhook`}
+            description={t`Specify a webhook URL where you can send the content of Alerts`}
+            onClick={() => setWebhookModal("create")}
+            icon="webhook"
+          />
+        )}
+      </Box>
+      <NotificationSettingsModals
+        modal={webhookModal}
+        channel={currentChannel}
+        onClose={() => setWebhookModal(null)}
+      />
+    </>
+  );
+};
+
+const ChannelBox = ({
+  title,
+  description,
+  onClick,
+  icon,
+}: {
+  title: string;
+  description?: string;
+  onClick: () => void;
+  icon: IconName;
+}) => (
+  <Paper
+    shadow="0"
+    withBorder
+    p="lg"
+    onClick={onClick}
+    style={{ cursor: "pointer" }}
+  >
+    <Flex gap="0.5rem" align="center">
+      <Icon name={icon} />
+      <Title order={2}>{title}</Title>
+    </Flex>
+    {description && <Text mt="0.5rem">{description}</Text>}
+  </Paper>
+);
+
+const NotificationSettingsModals = ({
+  modal,
+  channel,
+  onClose,
+}: {
+  modal: NotificationModals;
+  channel?: NotificationChannel;
+  onClose: () => void;
+}) => {
+  if (modal === "create") {
+    return <CreateWebhookModal isOpen onClose={onClose} />;
+  }
+  if (modal === "edit" && channel) {
+    return <EditWebhookModal isOpen onClose={onClose} channel={channel} />;
+  }
+  return null;
+};
diff --git a/frontend/src/metabase/admin/settings/notifications/WebhookForm.tsx b/frontend/src/metabase/admin/settings/notifications/WebhookForm.tsx
new file mode 100644
index 00000000000..bb7d150eab6
--- /dev/null
+++ b/frontend/src/metabase/admin/settings/notifications/WebhookForm.tsx
@@ -0,0 +1,306 @@
+import type { FormikHelpers } from "formik";
+import { jt, t } from "ttag";
+import * as Yup from "yup";
+
+import { useTestChannelMutation } from "metabase/api/channel";
+import ExternalLink from "metabase/core/components/ExternalLink";
+import {
+  Form,
+  FormChipGroup,
+  FormProvider,
+  FormSubmitButton,
+  FormTextInput,
+} from "metabase/forms";
+import { useActionButtonLabel } from "metabase/hooks/use-action-button-label";
+import { getResponseErrorMessage } from "metabase/lib/errors";
+import { useSelector } from "metabase/lib/redux";
+import { getDocsUrl } from "metabase/selectors/settings";
+import { Alert, Button, Chip, Flex, Group, Icon, Text } from "metabase/ui";
+import type {
+  NotificationAuthMethods,
+  NotificationAuthType,
+} from "metabase-types/api";
+
+import { buildAuthInfo } from "./utils";
+
+const validationSchema = Yup.object({
+  url: Yup.string()
+    .url(t`Please enter a correctly formatted URL`)
+    .required(t`Please enter a correctly formatted URL`),
+  name: Yup.string().required(t`Please add a name`),
+  description: Yup.string().required(t`Please add a description`),
+  "auth-method": Yup.string()
+    .required()
+    .equals(["none", "header", "query-param", "request-body"]),
+  "fe-form-type": Yup.string()
+    .required()
+    .equals(["none", "basic", "bearer", "api-key"]),
+  "auth-info": Yup.object(),
+});
+
+const styles = {
+  wrapperProps: {
+    fw: 400,
+  },
+  labelProps: {
+    fz: "0.875rem",
+    mb: "0.75rem",
+  },
+};
+
+export type WebhookFormProps = {
+  url: string;
+  name: string;
+  description: string;
+  "auth-method": NotificationAuthMethods;
+  "auth-info-key"?: string;
+  "auth-info-value"?: string;
+  "auth-username"?: string;
+  "auth-password"?: string;
+  "fe-form-type": NotificationAuthType;
+};
+
+type WebhookFormikHelpers = FormikHelpers<WebhookFormProps>;
+
+// Helper function to attempt to ensure that any error that comes back
+// is in the shape that our FormSubmit logic expects. This controls
+// highlighting the correct fields, etc. The shape can be hard to
+// determine because we forward responses from alert targets
+export const handleFieldError = (e: any) => {
+  if (!e.data) {
+    return;
+  } else if (typeof e.data === "string") {
+    throw { data: { errors: { url: e.data } } };
+  } else if (e.data.message) {
+    throw { data: { errors: { url: e.data.message } } };
+  } else if (typeof e.data.errors === "object") {
+    throw e;
+  }
+};
+
+const renderAuthSection = (type: string) => {
+  switch (type) {
+    case "basic":
+      return (
+        <>
+          <FormTextInput
+            name="auth-username"
+            label={t`Username`}
+            placeholder="user@email.com"
+            {...styles}
+            mb="1.5rem"
+          />
+          <FormTextInput
+            name="auth-password"
+            label={t`Password`}
+            placeholder="********"
+            {...styles}
+          />
+        </>
+      );
+    case "bearer":
+      return (
+        <FormTextInput
+          name="auth-info-value"
+          label={t`Bearer token`}
+          placeholder={t`Secret Token`}
+          {...styles}
+          mb="1.5rem"
+        />
+      );
+    case "api-key":
+      return (
+        <Flex direction="column">
+          <FormChipGroup
+            name="auth-method"
+            label={t`Add to`}
+            groupProps={{ mb: "1.5rem", mt: "0.5rem" }}
+          >
+            <Chip value="header" variant="brand">
+              {t`Header`}
+            </Chip>
+            <Chip value="query-param" variant="brand">
+              {t`Query param`}
+            </Chip>
+          </FormChipGroup>
+          <Flex gap="0.5rem">
+            <FormTextInput
+              name="auth-info-key"
+              label={t`Key`}
+              placeholder={t`API Key`}
+              {...styles}
+              mb="1.5rem"
+            />
+            <FormTextInput
+              name="auth-info-value"
+              label={t`Value`}
+              placeholder={t`API Key Value`}
+              {...styles}
+            />
+          </Flex>
+        </Flex>
+      );
+    default:
+      return null;
+  }
+};
+
+export const WebhookForm = ({
+  onSubmit,
+  onCancel,
+  onDelete,
+  initialValues,
+  submitLabel = t`Create destination`,
+}: {
+  onSubmit: (props: WebhookFormProps) => void;
+  onCancel: () => void;
+  onDelete?: () => void;
+  initialValues: WebhookFormProps;
+  submitLabel?: string;
+}) => {
+  const { label: testButtonLabel, setLabel: setTestButtonLabel } =
+    useActionButtonLabel({ defaultLabel: t`Send a test` });
+  const [testChannel] = useTestChannelMutation();
+
+  const docsUrl = useSelector(state =>
+    getDocsUrl(state, { page: "questions/sharing/alerts" }),
+  );
+
+  const handleTest = async (
+    values: WebhookFormProps,
+    setFieldError: WebhookFormikHelpers["setFieldError"],
+  ) => {
+    await testChannel({
+      details: {
+        url: values.url,
+        "auth-method": values["auth-method"],
+        "auth-info": buildAuthInfo(values),
+      },
+    })
+      .unwrap()
+      .then(
+        () => {
+          setFieldError("url", undefined);
+          setTestButtonLabel(t`Success`);
+        },
+        e => {
+          setTestButtonLabel(t`Test failed`);
+          const message =
+            typeof e === "string" ? e : getResponseErrorMessage(e);
+
+          setFieldError("url", message);
+        },
+      );
+  };
+
+  return (
+    <FormProvider
+      initialValues={initialValues}
+      onSubmit={onSubmit}
+      validationSchema={validationSchema}
+    >
+      {({ dirty, values, setFieldError, setFieldValue }) => (
+        <Form>
+          <Alert
+            variant="light"
+            mb="1.5rem"
+            style={{ backgroundColor: "var(--mb-color-bg-light)" }}
+            px="1.5rem"
+            py="1rem"
+            radius="0.5rem"
+          >
+            <Text>{jt`You can send the payload of any Alert to this destination whenever the Alert is triggered. ${(
+              <ExternalLink href={docsUrl}>
+                {t`Learn about Alerts`}
+              </ExternalLink>
+            )}`}</Text>
+          </Alert>
+          <Flex align="end" mb="1.5rem" gap="1rem">
+            <FormTextInput
+              name="url"
+              label={t`Webhook URL`}
+              placeholder="http://hooks.example.com/hooks/catch/"
+              style={{ flexGrow: 1 }}
+              {...styles}
+              maw="21rem"
+            />
+            <Button
+              h="2.5rem"
+              onClick={() => handleTest(values, setFieldError)}
+            >
+              {testButtonLabel}
+            </Button>
+          </Flex>
+          <FormTextInput
+            name="name"
+            label={t`Give it a name`}
+            placeholder={t`Something descriptive`}
+            {...styles}
+            mb="1.5rem"
+            maw="14.5rem"
+          />
+          <FormTextInput
+            name="description"
+            label={t`Description`}
+            placeholder={t`Where is this going and what does it send?`}
+            {...styles}
+            mb="1.5rem"
+            maw="21rem"
+          />
+          <FormChipGroup
+            name="fe-form-type"
+            label={t`Authentication method`}
+            groupProps={{ mb: "1.5rem", mt: "0.5rem" }}
+            onChange={val => {
+              if (val === "none") {
+                setFieldValue("auth-method", "none");
+              } else {
+                setFieldValue("auth-method", "header");
+              }
+            }}
+          >
+            <Chip value="none" variant="brand">
+              {t`None`}
+            </Chip>
+            <Chip value="basic" variant="brand">
+              {t`Basic`}
+            </Chip>
+            <Chip value="bearer" variant="brand">
+              {t`Bearer`}
+            </Chip>
+            <Chip value="api-key" variant="brand">
+              {t`API Key`}
+            </Chip>
+          </FormChipGroup>
+
+          {renderAuthSection(values["fe-form-type"])}
+
+          <Flex
+            mt="1.5rem"
+            justify={onDelete ? "space-between" : "end"}
+            gap="0.75rem"
+          >
+            {onDelete && (
+              <Button
+                variant="subtle"
+                c="var(--mb-color-text-medium)"
+                compact
+                pl="0"
+                leftIcon={<Icon name="trash" />}
+                onClick={onDelete}
+              >{t`Delete this destination`}</Button>
+            )}
+            <Group>
+              <Button onClick={onCancel}>{t`Cancel`}</Button>
+              <FormSubmitButton
+                disabled={!dirty}
+                label={submitLabel}
+                variant="filled"
+              />
+            </Group>
+          </Flex>
+        </Form>
+      )}
+    </FormProvider>
+  );
+};
diff --git a/frontend/src/metabase/admin/settings/notifications/WebhookForm.unit.spec.tsx b/frontend/src/metabase/admin/settings/notifications/WebhookForm.unit.spec.tsx
new file mode 100644
index 00000000000..3bc79b67627
--- /dev/null
+++ b/frontend/src/metabase/admin/settings/notifications/WebhookForm.unit.spec.tsx
@@ -0,0 +1,216 @@
+import userEvent from "@testing-library/user-event";
+import fetchMock from "fetch-mock";
+
+import { act, renderWithProviders, screen } from "__support__/ui";
+
+import { WebhookForm } from "./WebhookForm";
+
+const INITIAL_FORM_VALUES = {
+  url: "",
+  name: "",
+  description: "",
+  "fe-form-type": "none" as const,
+  "auth-method": "none" as const,
+};
+
+const setup = async ({
+  onSubmit = jest.fn(),
+  onCancel = jest.fn(),
+  onDelete = jest.fn(),
+  initialValues = INITIAL_FORM_VALUES,
+  populateForm = false,
+} = {}) => {
+  renderWithProviders(
+    <WebhookForm
+      onSubmit={onSubmit}
+      onCancel={onCancel}
+      onDelete={onDelete}
+      initialValues={initialValues}
+    />,
+  );
+
+  if (populateForm) {
+    await userEvent.type(
+      await screen.findByLabelText("Webhook URL"),
+      "http://my-awesome-hook.com/",
+    );
+    await userEvent.type(
+      await screen.findByLabelText("Give it a name"),
+      "The best hook",
+    );
+    await userEvent.type(
+      await screen.findByLabelText("Description"),
+      "Really though, it's the best",
+    );
+  }
+
+  return {
+    onSubmit,
+  };
+};
+
+describe("WebhookForm", () => {
+  afterEach(() => {
+    jest.useRealTimers();
+  });
+
+  it("should error when an invalid url is given", async () => {
+    await setup();
+    await userEvent.type(
+      await screen.findByLabelText("Webhook URL"),
+      "A-bad-url{tab}",
+    );
+    expect(
+      await screen.findByText("Please enter a correctly formatted URL"),
+    ).toBeInTheDocument();
+  });
+
+  it("should error when no name is provided", async () => {
+    await setup();
+    await userEvent.type(
+      await screen.findByLabelText("Give it a name"),
+      "{tab}",
+    );
+    expect(await screen.findByText("Please add a name")).toBeInTheDocument();
+  });
+
+  it("should error when no description is provided", async () => {
+    await setup();
+    await userEvent.type(await screen.findByLabelText("Description"), "{tab}");
+    expect(
+      await screen.findByText("Please add a description"),
+    ).toBeInTheDocument();
+  });
+
+  it("should show a username and password field when basic auth is selected", async () => {
+    const { onSubmit } = await setup({ populateForm: true });
+
+    await userEvent.click(await screen.findByRole("radio", { name: "Basic" }));
+
+    await userEvent.type(
+      await screen.findByLabelText("Username"),
+      "foo@bar.com",
+    );
+    await userEvent.type(await screen.findByLabelText("Password"), "pass");
+
+    await userEvent.click(
+      await screen.findByRole("button", { name: "Create destination" }),
+    );
+
+    expect(onSubmit).toHaveBeenCalledWith(
+      {
+        name: "The best hook",
+        description: "Really though, it's the best",
+        url: "http://my-awesome-hook.com/",
+        "auth-method": "header",
+        "fe-form-type": "basic",
+        "auth-username": "foo@bar.com",
+        "auth-password": "pass",
+      },
+      expect.anything(),
+    );
+  });
+
+  it("should show a token field when bearer auth is selected", async () => {
+    const { onSubmit } = await setup({ populateForm: true });
+
+    await userEvent.click(await screen.findByRole("radio", { name: "Bearer" }));
+
+    await userEvent.type(
+      await screen.findByLabelText("Bearer token"),
+      "SecretToken",
+    );
+    await userEvent.click(
+      await screen.findByRole("button", { name: "Create destination" }),
+    );
+
+    expect(onSubmit).toHaveBeenCalledWith(
+      {
+        name: "The best hook",
+        description: "Really though, it's the best",
+        url: "http://my-awesome-hook.com/",
+        "auth-method": "header",
+        "fe-form-type": "bearer",
+        "auth-info-value": "SecretToken",
+      },
+      expect.anything(),
+    );
+  });
+
+  it("should show a allow you to add a key/value pair to header or query param", async () => {
+    const { onSubmit } = await setup({ populateForm: true });
+
+    await userEvent.click(
+      await screen.findByRole("radio", { name: "API Key" }),
+    );
+    await userEvent.click(
+      await screen.findByRole("radio", { name: "Query param" }),
+    );
+
+    await userEvent.type(await screen.findByLabelText("Key"), "Foo");
+
+    await userEvent.type(await screen.findByLabelText("Value"), "Bar");
+
+    await userEvent.click(
+      await screen.findByRole("button", { name: "Create destination" }),
+    );
+
+    expect(onSubmit).toHaveBeenCalledWith(
+      {
+        name: "The best hook",
+        description: "Really though, it's the best",
+        url: "http://my-awesome-hook.com/",
+        "auth-method": "query-param",
+        "fe-form-type": "api-key",
+        "auth-info-key": "Foo",
+        "auth-info-value": "Bar",
+      },
+      expect.anything(),
+    );
+  });
+
+  it("should allow you to test a connection", async () => {
+    jest.useFakeTimers({
+      advanceTimers: true,
+    });
+
+    fetchMock.post("path:/api/channel/test", async (_url, opts) => {
+      const body = JSON.parse((await opts.body) as string);
+      return body.details.url?.endsWith("good") ? { ok: true } : 400;
+    });
+
+    await setup();
+
+    await userEvent.type(
+      screen.getByLabelText("Webhook URL"),
+      "http://my-awesome-hook.com/bad",
+    );
+
+    await userEvent.click(screen.getByRole("button", { name: "Send a test" }));
+
+    act(() => {
+      jest.advanceTimersByTime(1000);
+    });
+
+    expect(await screen.findByText("Test failed")).toBeInTheDocument();
+
+    act(() => jest.advanceTimersByTime(4000));
+
+    expect(screen.getByText("Send a test")).toBeInTheDocument();
+
+    await userEvent.clear(screen.getByLabelText("Webhook URL"));
+    await userEvent.type(
+      screen.getByLabelText("Webhook URL"),
+      "http://my-awesome-hook.com/good",
+    );
+
+    await userEvent.click(screen.getByRole("button", { name: "Send a test" }));
+    act(() => {
+      jest.advanceTimersByTime(1000);
+    });
+
+    expect(await screen.findByText("Success")).toBeInTheDocument();
+    act(() => jest.advanceTimersByTime(4000));
+    expect(screen.getByText("Send a test")).toBeInTheDocument();
+  });
+});
diff --git a/frontend/src/metabase/admin/settings/notifications/utils.ts b/frontend/src/metabase/admin/settings/notifications/utils.ts
new file mode 100644
index 00000000000..dbb58618c7c
--- /dev/null
+++ b/frontend/src/metabase/admin/settings/notifications/utils.ts
@@ -0,0 +1,52 @@
+import type { NotificationChannel } from "metabase-types/api";
+
+import type { WebhookFormProps } from "./WebhookForm";
+
+export const buildAuthInfo = (
+  form: WebhookFormProps,
+): Record<string, string> => {
+  const { "fe-form-type": authType } = form;
+  if (authType === "basic") {
+    const { "auth-username": username, "auth-password": password } = form;
+    return { Authorization: `Basic ${btoa(`${username}:${password}`)}` };
+  } else if (authType === "bearer") {
+    return { Authorization: `Bearer ${form["auth-info-value"]}` };
+  } else if (authType === "api-key") {
+    const { "auth-info-key": key, "auth-info-value": value } = form;
+    if (key && value) {
+      return { [key]: value };
+    }
+  }
+
+  return {};
+};
+
+export const channelToForm = ({ details }: NotificationChannel) => {
+  const { "fe-form-type": authType, "auth-info": authInfo } = details;
+
+  if (authType === "bearer") {
+    const token = authInfo?.["Authorization"];
+    return { "auth-info-value": token?.match(/Bearer (.*)/)?.[1] || "" };
+  }
+  if (authType === "basic") {
+    const info = authInfo?.["Authorization"];
+    const encoded = info?.match(/Basic (.*)/)?.[1];
+
+    if (encoded) {
+      const decoded = atob(encoded);
+      const [_, username, password] = decoded.match(/(.*):(.*)/) || [];
+
+      if (username && password) {
+        return {
+          "auth-username": username,
+          "auth-password": password,
+        };
+      }
+    }
+  }
+  if (authType === "api-key" && authInfo) {
+    const key = Object.keys(authInfo)[0];
+
+    return { "auth-info-key": key, "auth-info-value": authInfo[key] };
+  }
+};
diff --git a/frontend/src/metabase/admin/settings/notifications/utils.unit.spec.ts b/frontend/src/metabase/admin/settings/notifications/utils.unit.spec.ts
new file mode 100644
index 00000000000..9ad0bf0a488
--- /dev/null
+++ b/frontend/src/metabase/admin/settings/notifications/utils.unit.spec.ts
@@ -0,0 +1,99 @@
+import {
+  createMockChannel,
+  createMockChannelDetails,
+} from "metabase-types/api/mocks/channel";
+
+import type { WebhookFormProps } from "./WebhookForm";
+import { buildAuthInfo, channelToForm } from "./utils";
+
+const MockWebhookForm = (
+  opts: Partial<WebhookFormProps> & Pick<WebhookFormProps, "fe-form-type">,
+): WebhookFormProps => ({
+  url: "metabase.com",
+  name: "test",
+  description: "desc",
+  "auth-method": "header",
+  ...opts,
+});
+
+describe("Notification Utils", () => {
+  describe("buildAuthInfo", () => {
+    it("should handle basic auth forms", () => {
+      const authInfo = buildAuthInfo(
+        MockWebhookForm({
+          "fe-form-type": "basic",
+          "auth-username": "user",
+          "auth-password": "pass",
+        }),
+      );
+      expect(authInfo).toHaveProperty(
+        "Authorization",
+        `Basic ${btoa("user:pass")}`,
+      );
+    });
+
+    it("should handle bearer auth forms", () => {
+      const authInfo = buildAuthInfo(
+        MockWebhookForm({
+          "fe-form-type": "bearer",
+          "auth-info-value": "MyToken",
+        }),
+      );
+      expect(authInfo).toHaveProperty("Authorization", "Bearer MyToken");
+    });
+
+    it("should handle api-key forms", () => {
+      const authInfo = buildAuthInfo(
+        MockWebhookForm({
+          "fe-form-type": "api-key",
+          "auth-info-key": "key",
+          "auth-info-value": "token",
+        }),
+      );
+      expect(authInfo).toHaveProperty("key", "token");
+    });
+  });
+
+  describe("channelToForm", () => {
+    it("should handle basic form type", () => {
+      const formProps = channelToForm(
+        createMockChannel({
+          details: createMockChannelDetails({
+            "fe-form-type": "basic",
+            "auth-info": { Authorization: `Basic ${btoa("user:pass")}` },
+          }),
+        }),
+      );
+      expect(formProps).toHaveProperty("auth-username", "user");
+      expect(formProps).toHaveProperty("auth-password", "pass");
+    });
+
+    it("should handle bearer form type", () => {
+      const formProps = channelToForm(
+        createMockChannel({
+          details: createMockChannelDetails({
+            "fe-form-type": "bearer",
+            "auth-info": { Authorization: `Bearer MyToken` },
+          }),
+        }),
+      );
+      expect(formProps).toHaveProperty("auth-info-value", "MyToken");
+    });
+
+    it("should handle api-key form type", () => {
+      const formProps = channelToForm(
+        createMockChannel({
+          details: createMockChannelDetails({
+            "fe-form-type": "api-key",
+            "auth-info": { "x-auth-token": "NobodyWillGuessThis" },
+          }),
+        }),
+      );
+      expect(formProps).toHaveProperty(
+        "auth-info-value",
+        "NobodyWillGuessThis",
+      );
+      expect(formProps).toHaveProperty("auth-info-key", "x-auth-token");
+    });
+  });
+});
diff --git a/frontend/src/metabase/admin/settings/selectors.js b/frontend/src/metabase/admin/settings/selectors.js
index 2c462689f7f..f7afc9b620a 100644
--- a/frontend/src/metabase/admin/settings/selectors.js
+++ b/frontend/src/metabase/admin/settings/selectors.js
@@ -43,6 +43,7 @@ import RedirectWidget from "./components/widgets/RedirectWidget";
 import SecretKeyWidget from "./components/widgets/SecretKeyWidget";
 import SettingCommaDelimitedInput from "./components/widgets/SettingCommaDelimitedInput";
 import SiteUrlWidget from "./components/widgets/SiteUrlWidget";
+import { NotificationSettings } from "./notifications/NotificationSettings";
 import { updateSetting } from "./settings";
 import SetupCheckList from "./setup/components/SetupCheckList";
 import SlackSettings from "./slack/containers/SlackSettings";
@@ -277,12 +278,18 @@ export const ADMIN_SETTINGS_SECTIONS = {
       },
     ],
   },
-  slack: {
+  "notifications/slack": {
     name: "Slack",
     order: 50,
     component: SlackSettings,
     settings: [],
   },
+  notifications: {
+    name: t`Notification channels`,
+    order: 51,
+    component: NotificationSettings,
+    settings: [],
+  },
   authentication: {
     name: t`Authentication`,
     order: 60,
diff --git a/frontend/src/metabase/api/alert.ts b/frontend/src/metabase/api/alert.ts
index 1024df6da83..9fe5572cac3 100644
--- a/frontend/src/metabase/api/alert.ts
+++ b/frontend/src/metabase/api/alert.ts
@@ -1,5 +1,6 @@
 import type {
   Alert,
+  AlertCard,
   AlertId,
   CreateAlertRequest,
   ListAlertsRequest,
@@ -70,6 +71,13 @@ export const alertApi = Api.injectEndpoints({
       invalidatesTags: (_, error, id) =>
         invalidateTags(error, [listTag("alert"), idTag("alert", id)]),
     }),
+    testAlert: builder.mutation<void, Partial<Alert> & { cards: AlertCard[] }>({
+      query: body => ({
+        method: "POST",
+        url: `/api/pulse/test`,
+        body,
+      }),
+    }),
   }),
 });
 
@@ -80,4 +88,5 @@ export const {
   useCreateAlertMutation,
   useUpdateAlertMutation,
   useDeleteAlertSubscriptionMutation,
+  useTestAlertMutation,
 } = alertApi;
diff --git a/frontend/src/metabase/api/channel.ts b/frontend/src/metabase/api/channel.ts
new file mode 100644
index 00000000000..e3280edaa22
--- /dev/null
+++ b/frontend/src/metabase/api/channel.ts
@@ -0,0 +1,70 @@
+import type { ChannelDetails, NotificationChannel } from "metabase-types/api";
+
+import { Api } from "./api";
+import { idTag, invalidateTags, listTag, provideChannelListTags } from "./tags";
+
+const channelApi = Api.injectEndpoints({
+  endpoints: builder => ({
+    listChannels: builder.query<NotificationChannel[], void>({
+      query: () => `api/channel`,
+      providesTags: (channels = []) => provideChannelListTags(channels),
+    }),
+    testChannel: builder.mutation<
+      void,
+      { details: Omit<ChannelDetails, "fe-form-type"> }
+    >({
+      query: body => ({
+        method: "POST",
+        url: "api/channel/test",
+        body: {
+          ...body,
+          type: "channel/http",
+        },
+      }),
+    }),
+    createChannel: builder.mutation<
+      NotificationChannel[],
+      Omit<NotificationChannel, "created_at" | "updated_at" | "active" | "id">
+    >({
+      query: body => ({
+        method: "POST",
+        url: "api/channel",
+        body,
+      }),
+      invalidatesTags: (_, error) =>
+        invalidateTags(error, [listTag("channel")]),
+    }),
+    editChannel: builder.mutation<
+      NotificationChannel[],
+      Omit<NotificationChannel, "created_at" | "updated_at" | "active" | "type">
+    >({
+      query: ({ id, ...body }) => ({
+        method: "PUT",
+        url: `api/channel/${id}`,
+        body,
+      }),
+      invalidatesTags: (_, error, { id }) =>
+        invalidateTags(error, [listTag("channel"), idTag("channel", id)]),
+    }),
+    deleteChannel: builder.mutation<void, number>({
+      query: id => ({
+        method: "PUT",
+        url: `api/channel/${id}`,
+        body: {
+          active: false,
+        },
+      }),
+      invalidatesTags: (_, error) =>
+        invalidateTags(error, [listTag("channel")]),
+    }),
+  }),
+});
+
+export const {
+  useListChannelsQuery,
+  useEditChannelMutation,
+  useCreateChannelMutation,
+  useDeleteChannelMutation,
+  useTestChannelMutation,
+  endpoints: { listChannels },
+} = channelApi;
diff --git a/frontend/src/metabase/api/tags/constants.ts b/frontend/src/metabase/api/tags/constants.ts
index 0dac02bbd19..36899b1d267 100644
--- a/frontend/src/metabase/api/tags/constants.ts
+++ b/frontend/src/metabase/api/tags/constants.ts
@@ -7,6 +7,7 @@ export const TAG_TYPES = [
   "bookmark",
   "card",
   "cloud-migration",
+  "channel",
   "collection",
   "dashboard",
   "database",
diff --git a/frontend/src/metabase/api/tags/utils.ts b/frontend/src/metabase/api/tags/utils.ts
index af654a77e96..40ab1d4bc5c 100644
--- a/frontend/src/metabase/api/tags/utils.ts
+++ b/frontend/src/metabase/api/tags/utils.ts
@@ -25,6 +25,7 @@ import type {
   ModelCacheRefreshStatus,
   ModelIndex,
   NativeQuerySnippet,
+  NotificationChannel,
   PopularItem,
   RecentItem,
   Revision,
@@ -206,6 +207,21 @@ export function provideModelIndexListTags(
   ];
 }
 
+export function provideChannelTags(
+  channel: NotificationChannel,
+): TagDescription<TagType>[] {
+  return [idTag("channel", channel.id)];
+}
+
+export function provideChannelListTags(
+  channels: NotificationChannel[],
+): TagDescription<TagType>[] {
+  return [
+    listTag("channel"),
+    ...channels.flatMap(channel => provideChannelTags(channel)),
+  ];
+}
+
 export function provideDatabaseCandidateListTags(
   candidates: DatabaseXray[],
 ): TagDescription<TagType>[] {
diff --git a/enterprise/frontend/src/metabase-enterprise/sandboxes/components/MappingEditor.tsx b/frontend/src/metabase/core/components/MappingEditor/MappingEditor.tsx
similarity index 95%
rename from enterprise/frontend/src/metabase-enterprise/sandboxes/components/MappingEditor.tsx
rename to frontend/src/metabase/core/components/MappingEditor/MappingEditor.tsx
index 9a1801a506d..d7104b2a519 100644
--- a/enterprise/frontend/src/metabase-enterprise/sandboxes/components/MappingEditor.tsx
+++ b/frontend/src/metabase/core/components/MappingEditor/MappingEditor.tsx
@@ -5,7 +5,7 @@ import { t } from "ttag";
 import _ from "underscore";
 
 import CS from "metabase/css/core/index.css";
-import { Button, Icon, TextInput } from "metabase/ui";
+import { Button, type ButtonProps, Icon, TextInput } from "metabase/ui";
 
 type DefaultRenderInputProps = {
   value: MappingValue;
@@ -31,7 +31,7 @@ const DefaultRenderInput = ({
 type MappingValue = string;
 type MappingType = Record<string, MappingValue>;
 
-interface MappingEditorProps {
+export interface MappingEditorProps {
   value: MappingType;
   onChange: (val: MappingType) => void;
   onError?: (val: boolean) => void;
@@ -47,6 +47,7 @@ interface MappingEditorProps {
   canAdd?: boolean;
   canDelete?: boolean;
   addText?: string;
+  addButtonProps?: ButtonProps;
   swapKeyAndValue?: boolean;
 }
 
@@ -94,6 +95,7 @@ export const MappingEditor = ({
   canAdd = true,
   canDelete = true,
   addText = "Add",
+  addButtonProps,
   swapKeyAndValue,
 }: MappingEditorProps) => {
   const [entries, setEntries] = useState<Entry[]>(buildEntries(mapping));
@@ -154,7 +156,7 @@ export const MappingEditor = ({
                     variant="subtle"
                     onClick={() => handleChange(removeEntry(entries, index))}
                     color={"text"}
-                    data-testId="remove-mapping"
+                    data-testid="remove-mapping"
                   />
                 </td>
               )}
@@ -169,6 +171,7 @@ export const MappingEditor = ({
                   leftIcon={<Icon name="add" />}
                   variant="subtle"
                   onClick={() => handleChange(addEntry(entries))}
+                  {...addButtonProps}
                 >
                   {addText}
                 </Button>
diff --git a/frontend/src/metabase/core/components/MappingEditor/index.ts b/frontend/src/metabase/core/components/MappingEditor/index.ts
new file mode 100644
index 00000000000..23ff4d0a916
--- /dev/null
+++ b/frontend/src/metabase/core/components/MappingEditor/index.ts
@@ -0,0 +1 @@
+export * from "./MappingEditor";
diff --git a/frontend/src/metabase/forms/components/FormChipGroup/FormChipGroup.tsx b/frontend/src/metabase/forms/components/FormChipGroup/FormChipGroup.tsx
new file mode 100644
index 00000000000..15db4a83929
--- /dev/null
+++ b/frontend/src/metabase/forms/components/FormChipGroup/FormChipGroup.tsx
@@ -0,0 +1,50 @@
+import { useField } from "formik";
+import type { Ref } from "react";
+import { forwardRef, useCallback } from "react";
+
+import type { ChipGroupProps, GroupProps, TextProps } from "metabase/ui";
+import { Chip, Group, Text } from "metabase/ui";
+
+export interface FormChipGroupProps
+  extends Omit<ChipGroupProps, "value" | "error"> {
+  name: string;
+  label: string;
+  groupProps?: GroupProps;
+  labelProps?: TextProps;
+}
+
+export const FormChipGroup = forwardRef(function FormChipGroup(
+  {
+    name,
+    onChange,
+    label,
+    children,
+    groupProps,
+    labelProps,
+    ...props
+  }: FormChipGroupProps,
+  ref: Ref<HTMLDivElement>,
+) {
+  const [{ value }, _, { setValue }] = useField(name);
+
+  const handleChange = useCallback(
+    (newValue: string) => {
+      setValue(newValue);
+      onChange?.(newValue);
+    },
+    [setValue, onChange],
+  );
+
+  return (
+    <Chip.Group {...props} value={value ?? undefined} onChange={handleChange}>
+      {label && (
+        <Text component="label" fw="bold" {...labelProps}>
+          {label}
+        </Text>
+      )}
+      <Group ref={ref} {...groupProps}>
+        {children}
+      </Group>
+    </Chip.Group>
+  );
+});
diff --git a/frontend/src/metabase/forms/components/FormChipGroup/index.ts b/frontend/src/metabase/forms/components/FormChipGroup/index.ts
new file mode 100644
index 00000000000..0ee7e361622
--- /dev/null
+++ b/frontend/src/metabase/forms/components/FormChipGroup/index.ts
@@ -0,0 +1 @@
+export * from "./FormChipGroup";
diff --git a/frontend/src/metabase/forms/components/FormKeyValueMapping/FormKeyValueMapping.tsx b/frontend/src/metabase/forms/components/FormKeyValueMapping/FormKeyValueMapping.tsx
new file mode 100644
index 00000000000..ae6e73a302b
--- /dev/null
+++ b/frontend/src/metabase/forms/components/FormKeyValueMapping/FormKeyValueMapping.tsx
@@ -0,0 +1,47 @@
+import { useField } from "formik";
+import { t } from "ttag";
+
+import {
+  MappingEditor,
+  type MappingEditorProps,
+} from "metabase/core/components/MappingEditor";
+import { Box, type BoxProps, Text } from "metabase/ui";
+
+type Props = BoxProps & {
+  name: string;
+  label?: string;
+  mappingEditorProps?: Partial<MappingEditorProps>;
+};
+
+export const FormKeyValueMapping = ({
+  name = "login_attributes",
+  label = t`Attributes`,
+  mappingEditorProps,
+  ...props
+}: Props) => {
+  const [{ value }, , { setValue, setError }] = useField(name);
+
+  const handleError = (error: boolean) => {
+    if (error) {
+      setError(t`Duplicate login attribute keys`);
+    }
+  };
+
+  return (
+    <Box {...props}>
+      {label && (
+        <Text component="label" fw="bold">
+          {label}
+        </Text>
+      )}
+
+      <MappingEditor
+        value={value || {}}
+        onChange={setValue}
+        onError={handleError}
+        addText={t`Add an attribute`}
+        {...mappingEditorProps}
+      />
+    </Box>
+  );
+};
diff --git a/frontend/src/metabase/forms/components/FormKeyValueMapping/index.ts b/frontend/src/metabase/forms/components/FormKeyValueMapping/index.ts
new file mode 100644
index 00000000000..22d92cb3f10
--- /dev/null
+++ b/frontend/src/metabase/forms/components/FormKeyValueMapping/index.ts
@@ -0,0 +1 @@
+export * from "./FormKeyValueMapping";
diff --git a/frontend/src/metabase/forms/components/index.ts b/frontend/src/metabase/forms/components/index.ts
index cd36d8d229d..4c65bc5d455 100644
--- a/frontend/src/metabase/forms/components/index.ts
+++ b/frontend/src/metabase/forms/components/index.ts
@@ -1,6 +1,7 @@
 export * from "./Form";
 export * from "./FormCheckbox";
 export * from "./FormCheckboxGroup";
+export * from "./FormChipGroup";
 export * from "./FormErrorMessage";
 export * from "./FormGroupsWidget";
 export * from "./FormGroupWidget";
diff --git a/frontend/src/metabase/hooks/use-action-button-label/index.ts b/frontend/src/metabase/hooks/use-action-button-label/index.ts
new file mode 100644
index 00000000000..8babf46ed0f
--- /dev/null
+++ b/frontend/src/metabase/hooks/use-action-button-label/index.ts
@@ -0,0 +1 @@
+export * from "./use-action-button-label";
diff --git a/frontend/src/metabase/hooks/use-action-button-label/use-action-button-label.ts b/frontend/src/metabase/hooks/use-action-button-label/use-action-button-label.ts
new file mode 100644
index 00000000000..587e17971a4
--- /dev/null
+++ b/frontend/src/metabase/hooks/use-action-button-label/use-action-button-label.ts
@@ -0,0 +1,33 @@
+import { type ReactNode, useRef, useState } from "react";
+
+interface UseActionButtonLabelProps {
+  defaultLabel: string | ReactNode;
+  timeout?: number;
+}
+
+/**
+ * Small hook to temporarly update a string, and return it to it's
+ * initial value after the timeout expires.
+ */
+
+export const useActionButtonLabel = ({
+  defaultLabel,
+  timeout = 3000,
+}: UseActionButtonLabelProps) => {
+  const [label, setLabel] = useState(defaultLabel);
+  const timeoutId = useRef<NodeJS.Timeout>();
+
+  const handleUpdateLabel = (newLabel: string) => {
+    clearTimeout(timeoutId.current);
+    setLabel(newLabel);
+
+    timeoutId.current = setTimeout(() => {
+      setLabel(defaultLabel);
+    }, timeout);
+  };
+
+  return {
+    label,
+    setLabel: handleUpdateLabel,
+  };
+};
diff --git a/frontend/src/metabase/hooks/use-action-button-label/use-action-button-label.unit.spec.tsx b/frontend/src/metabase/hooks/use-action-button-label/use-action-button-label.unit.spec.tsx
new file mode 100644
index 00000000000..022e81019f3
--- /dev/null
+++ b/frontend/src/metabase/hooks/use-action-button-label/use-action-button-label.unit.spec.tsx
@@ -0,0 +1,68 @@
+import { act, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+
+import { useActionButtonLabel } from "./use-action-button-label";
+
+const TestComponent = () => {
+  const { label, setLabel } = useActionButtonLabel({
+    defaultLabel: "default",
+    timeout: 1000,
+  });
+
+  return (
+    <div>
+      <button onClick={() => setLabel("success")}>Success</button>
+      <button onClick={() => setLabel("failed")}>Fail</button>
+      <div>Current label: {label}</div>
+    </div>
+  );
+};
+
+jest.useFakeTimers({
+  advanceTimers: true,
+});
+
+describe("useActionButtonLabel", () => {
+  it("should show the default label when rendered", async () => {
+    render(<TestComponent />);
+    expect(screen.getByText("Current label: default")).toBeInTheDocument();
+  });
+
+  it("should update value, and return to default after the timeout", async () => {
+    render(<TestComponent />);
+    expect(screen.getByText("Current label: default")).toBeInTheDocument();
+
+    await userEvent.click(screen.getByText("Success"));
+
+    expect(screen.getByText("Current label: success")).toBeInTheDocument();
+
+    act(() => jest.advanceTimersByTime(1000));
+
+    expect(screen.getByText("Current label: default")).toBeInTheDocument();
+  });
+
+  it("should should update value, and return to default after the timeout", async () => {
+    render(<TestComponent />);
+    expect(screen.getByText("Current label: default")).toBeInTheDocument();
+
+    await userEvent.click(screen.getByText("Success"));
+
+    expect(screen.getByText("Current label: success")).toBeInTheDocument();
+
+    act(() => jest.advanceTimersByTime(500));
+
+    expect(screen.getByText("Current label: success")).toBeInTheDocument();
+
+    //Update the label half way through the timeout
+    await userEvent.click(screen.getByText("Fail"));
+    expect(screen.getByText("Current label: failed")).toBeInTheDocument();
+
+    //Previous timeout should have been cleared, should still say failed
+    act(() => jest.advanceTimersByTime(500));
+    expect(screen.getByText("Current label: failed")).toBeInTheDocument();
+
+    //Second update timeout completes, should go back to default text
+    act(() => jest.advanceTimersByTime(500));
+    expect(screen.getByText("Current label: default")).toBeInTheDocument();
+  });
+});
diff --git a/frontend/src/metabase/lib/alert.js b/frontend/src/metabase/lib/alert.js
index 56e31f35e53..206ae1aeea5 100644
--- a/frontend/src/metabase/lib/alert.js
+++ b/frontend/src/metabase/lib/alert.js
@@ -11,6 +11,8 @@ export function channelIsValid(channel) {
       );
     case "slack":
       return channel.details && scheduleIsValid(channel);
+    case "http":
+      return channel.channel_id && scheduleIsValid(channel);
     default:
       return false;
   }
diff --git a/frontend/src/metabase/lib/pulse.ts b/frontend/src/metabase/lib/pulse.ts
index e4e41df7f24..fe372a57b6c 100644
--- a/frontend/src/metabase/lib/pulse.ts
+++ b/frontend/src/metabase/lib/pulse.ts
@@ -39,6 +39,8 @@ export function channelIsValid(channel: Channel, channelSpec: ChannelSpec) {
         fieldsAreValid(channel, channelSpec) &&
         scheduleIsValid(channel)
       );
+    case "http":
+      return channel.channel_id && scheduleIsValid(channel);
     default:
       return false;
   }
@@ -178,7 +180,10 @@ export function getDefaultChannel(channelSpecs: ChannelSpecs) {
   }
 }
 
-export function createChannel(channelSpec: ChannelSpec): Channel {
+export function createChannel(
+  channelSpec: ChannelSpec,
+  opts?: Partial<Channel>,
+): Channel {
   const details = {};
 
   return {
@@ -190,6 +195,7 @@ export function createChannel(channelSpec: ChannelSpec): Channel {
     schedule_day: "mon",
     schedule_hour: 8,
     schedule_frame: "first",
+    ...opts,
   };
 }
 
diff --git a/frontend/src/metabase/pulse/components/EmailChannelEdit.tsx b/frontend/src/metabase/pulse/components/EmailChannelEdit.tsx
new file mode 100644
index 00000000000..a3120061324
--- /dev/null
+++ b/frontend/src/metabase/pulse/components/EmailChannelEdit.tsx
@@ -0,0 +1,79 @@
+import cx from "classnames";
+import { t } from "ttag";
+
+import ChannelSetupMessage from "metabase/components/ChannelSetupMessage";
+import CS from "metabase/css/core/index.css";
+import { Icon, Switch } from "metabase/ui";
+import type { Alert, EmailChannelSpec, User } from "metabase-types/api";
+
+import { RecipientPicker } from "./RecipientPicker";
+
+export const EmailChannelEdit = ({
+  channelSpec,
+  alert,
+  toggleChannel,
+  onChannelPropertyChange,
+  users,
+  user,
+  invalidRecipientText,
+}: {
+  channelSpec: EmailChannelSpec;
+  alert: Alert;
+  toggleChannel: (channel: "email", index: number, value: boolean) => void;
+  user: User;
+  users: User[];
+  onChannelPropertyChange: (index: number, name: string, value: any) => void;
+  invalidRecipientText: (domains: string) => string;
+}) => {
+  const channelIndex = alert.channels.findIndex(
+    channel => channel.channel_type === "email",
+  );
+  const channel = alert.channels[channelIndex];
+
+  const handleRecipientsChange = (recipients: User[]) =>
+    onChannelPropertyChange(channelIndex, "recipients", recipients);
+
+  return (
+    <li className={CS.borderRowDivider}>
+      <div className={cx(CS.flex, CS.alignCenter, CS.p3, CS.borderRowDivider)}>
+        <Icon className={cx(CS.mr1, CS.textLight)} name="mail" size={28} />
+
+        <h2>{channelSpec.name}</h2>
+        <Switch
+          className={CS.flexAlignRight}
+          checked={channel?.enabled}
+          onChange={val =>
+            toggleChannel("email", channelIndex, val.target.checked)
+          }
+        />
+      </div>
+      {channel?.enabled && channelSpec.configured ? (
+        <ul className={cx(CS.bgLight, CS.px3)}>
+          <li className={CS.py2}>
+            <div>
+              <div className={cx(CS.h4, CS.textBold, CS.mb1)}>
+                {t`Email alerts to:`}
+              </div>
+              <RecipientPicker
+                autoFocus={!!alert.name}
+                recipients={channel.recipients}
+                users={users}
+                onRecipientsChange={handleRecipientsChange}
+                invalidRecipientText={invalidRecipientText}
+              />
+            </div>
+          </li>
+
+          {/* {renderChannel(channel, channelSpec, channelIndex)} */}
+        </ul>
+      ) : channel?.enabled && !channelSpec.configured ? (
+        <div className={cx(CS.p4, CS.textCentered)}>
+          <h3
+            className={CS.mb2}
+          >{t`${channelSpec.name} needs to be set up by an administrator.`}</h3>
+          <ChannelSetupMessage user={user} channels={[channelSpec.name]} />
+        </div>
+      ) : null}
+    </li>
+  );
+};
diff --git a/frontend/src/metabase/pulse/components/PulseEditChannels.tsx b/frontend/src/metabase/pulse/components/PulseEditChannels.tsx
index 2e1a461039c..23f0a8c5ec5 100644
--- a/frontend/src/metabase/pulse/components/PulseEditChannels.tsx
+++ b/frontend/src/metabase/pulse/components/PulseEditChannels.tsx
@@ -1,40 +1,40 @@
 /* eslint "react/prop-types": "warn" */
 import cx from "classnames";
-import { assoc } from "icepick";
+import { assoc, updateIn } from "icepick";
 import { t } from "ttag";
 import _ from "underscore";
 
-import ChannelSetupMessage from "metabase/components/ChannelSetupMessage";
-import Toggle from "metabase/core/components/Toggle";
+import { useListChannelsQuery } from "metabase/api/channel";
 import CS from "metabase/css/core/index.css";
 import { createChannel } from "metabase/lib/pulse";
-import SlackChannelField from "metabase/sharing/components/SlackChannelField";
-import { Icon, type IconName } from "metabase/ui";
+import type { IconName } from "metabase/ui";
 import type {
   Alert,
   Channel,
   ChannelApiResponse,
-  ChannelSpec,
   ChannelType,
-  Pulse,
+  NotificationChannel,
   User,
 } from "metabase-types/api";
 
-import { RecipientPicker } from "./RecipientPicker";
+import { EmailChannelEdit } from "./EmailChannelEdit";
+import { SlackChannelEdit } from "./SlackChannelEdit";
+import { WebhookChannelEdit } from "./WebhookChannelEdit";
 
 export const CHANNEL_ICONS: Record<ChannelType, IconName> = {
   email: "mail",
   slack: "slack",
+  http: "webhook",
 };
 
 interface PulseEditChannelsProps {
-  pulse: Pulse;
+  pulse: Alert;
   pulseId: Alert["id"];
   pulseIsValid: boolean;
   formInput: ChannelApiResponse;
   user: User;
   users: User[];
-  setPulse: (value: Pulse) => void;
+  setPulse: (value: Alert) => void;
   hideSchedulePicker: boolean;
   emailRecipientText: string;
   invalidRecipientText: (domains: string) => string;
@@ -46,16 +46,23 @@ export const PulseEditChannels = ({
   user,
   users,
   setPulse,
-  emailRecipientText,
   invalidRecipientText,
 }: PulseEditChannelsProps) => {
-  const addChannel = (type: ChannelType) => {
+  const { data: notificationChannels = [] } = useListChannelsQuery();
+
+  const addChannel = (
+    type: ChannelType,
+    notification?: NotificationChannel,
+  ) => {
     const channelSpec = formInput.channels[type];
     if (!channelSpec) {
       return;
     }
 
-    const channel = createChannel(channelSpec);
+    const channel = createChannel(
+      channelSpec,
+      notification ? { channel_id: notification.id } : undefined,
+    );
 
     setPulse({ ...pulse, channels: pulse.channels.concat(channel) });
   };
@@ -68,136 +75,74 @@ export const PulseEditChannels = ({
     setPulse({ ...pulse, channels });
   };
 
-  const toggleChannel = (type: ChannelType, enable: boolean) => {
+  const toggleChannel = (
+    type: ChannelType,
+    index: number,
+    enable: boolean,
+    notification?: NotificationChannel,
+  ) => {
     if (enable) {
-      if (pulse.channels.some(c => c.channel_type === type)) {
+      if (pulse.channels[index]) {
         setPulse(
-          assoc(
-            pulse,
-            "channels",
-            pulse.channels.map(c =>
-              c.channel_type === type ? assoc(c, "enabled", true) : c,
-            ),
+          updateIn(pulse, ["channels", index], (channel: Channel) =>
+            assoc(channel, "enabled", true),
           ),
         );
       } else {
-        addChannel(type);
+        addChannel(type, notification);
       }
     } else {
-      const channel = pulse.channels.find(
-        channel => channel.channel_type === type,
-      );
+      const channel = pulse.channels[index];
 
       const shouldRemoveChannel =
         type === "email" && channel?.recipients?.length === 0;
 
       const updatedPulse = shouldRemoveChannel
-        ? assoc(
-            pulse,
-            "channels",
-            pulse.channels.filter(channel => channel.channel_type !== type),
+        ? updateIn(pulse, ["channels"], channels =>
+            channels.toSpliced(index, 1),
           )
-        : assoc(
-            pulse,
-            "channels",
-            pulse.channels.map(c =>
-              c.channel_type === type ? assoc(c, "enabled", false) : c,
-            ),
+        : updateIn(pulse, ["channels", index], (channel: Channel) =>
+            assoc(channel, "enabled", false),
           );
-
       setPulse(updatedPulse);
     }
   };
 
-  const renderChannel = (
-    channel: Channel,
-    index: number,
-    channelSpec: ChannelSpec,
-  ) => {
-    return (
-      <li key={index} className={CS.py2}>
-        {channelSpec.error && (
-          <div className={cx(CS.pb2, CS.textBold, CS.textError)}>
-            {channelSpec.error}
-          </div>
-        )}
-        {channelSpec.recipients && (
-          <div>
-            <div className={cx(CS.h4, CS.textBold, CS.mb1)}>
-              {emailRecipientText || t`To:`}
-            </div>
-            <RecipientPicker
-              autoFocus={!!pulse.name}
-              recipients={channel.recipients}
-              users={users}
-              onRecipientsChange={(recipients: User[]) =>
-                onChannelPropertyChange(index, "recipients", recipients)
-              }
-              invalidRecipientText={invalidRecipientText}
-            />
-          </div>
-        )}
-        {channelSpec.type === "slack" ? (
-          <SlackChannelField
-            channel={channel}
-            channelSpec={channelSpec}
-            onChannelPropertyChange={(name: string, value: any) =>
-              onChannelPropertyChange(index, name, value)
-            }
-          />
-        ) : null}
-      </li>
-    );
-  };
-
-  const renderChannelSection = (channelSpec: ChannelSpec) => {
-    const channels = pulse.channels
-      .map((c, i) => [c, i] as [Channel, number])
-      .filter(([c]) => c.enabled && c.channel_type === channelSpec.type)
-      .map(([channel, index]) => renderChannel(channel, index, channelSpec));
-    return (
-      <li key={channelSpec.type} className={CS.borderRowDivider}>
-        <div
-          className={cx(CS.flex, CS.alignCenter, CS.p3, CS.borderRowDivider)}
-        >
-          {CHANNEL_ICONS[channelSpec.type] && (
-            <Icon
-              className={cx(CS.mr1, CS.textLight)}
-              name={CHANNEL_ICONS[channelSpec.type]}
-              size={28}
-            />
-          )}
-          <h2>{channelSpec.name}</h2>
-          <Toggle
-            className={CS.flexAlignRight}
-            value={channels.length > 0}
-            onChange={val => toggleChannel(channelSpec.type, val)}
-          />
-        </div>
-        {channels.length > 0 && channelSpec.configured ? (
-          <ul className={cx(CS.bgLight, CS.px3)}>{channels}</ul>
-        ) : channels.length > 0 && !channelSpec.configured ? (
-          <div className={cx(CS.p4, CS.textCentered)}>
-            <h3
-              className={CS.mb2}
-            >{t`${channelSpec.name} needs to be set up by an administrator.`}</h3>
-            <ChannelSetupMessage user={user} channels={[channelSpec.name]} />
-          </div>
-        ) : null}
-      </li>
-    );
-  };
-
   // Default to show the default channels until full formInput is loaded
   const channels = formInput.channels || {
     email: { name: t`Email`, type: "email" },
     slack: { name: t`Slack`, type: "slack" },
+    http: { name: t`Http`, type: "http" },
   };
+
   return (
     <ul className={cx(CS.bordered, CS.rounded, CS.bgWhite)}>
-      {Object.values(channels).map(channelSpec =>
-        renderChannelSection(channelSpec),
-      )}
+      <EmailChannelEdit
+        user={user}
+        users={users}
+        toggleChannel={toggleChannel}
+        onChannelPropertyChange={onChannelPropertyChange}
+        channelSpec={channels.email}
+        alert={pulse}
+        invalidRecipientText={invalidRecipientText}
+      />
+      <SlackChannelEdit
+        user={user}
+        toggleChannel={toggleChannel}
+        onChannelPropertyChange={onChannelPropertyChange}
+        channelSpec={channels.slack}
+        alert={pulse}
+      />
+      {notificationChannels.map(notification => (
+        <WebhookChannelEdit
+          key={`webhook-${notification.id}`}
+          user={user}
+          toggleChannel={toggleChannel}
+          channelSpec={channels.http}
+          alert={pulse}
+          notification={notification}
+        />
+      ))}
     </ul>
   );
 };
diff --git a/frontend/src/metabase/pulse/components/SlackChannelEdit.tsx b/frontend/src/metabase/pulse/components/SlackChannelEdit.tsx
new file mode 100644
index 00000000000..9b60a58ac37
--- /dev/null
+++ b/frontend/src/metabase/pulse/components/SlackChannelEdit.tsx
@@ -0,0 +1,64 @@
+import cx from "classnames";
+import { t } from "ttag";
+
+import ChannelSetupMessage from "metabase/components/ChannelSetupMessage";
+import CS from "metabase/css/core/index.css";
+import SlackChannelField from "metabase/sharing/components/SlackChannelField";
+import { Icon, Switch } from "metabase/ui";
+import type { Alert, SlackChannelSpec, User } from "metabase-types/api";
+
+export const SlackChannelEdit = ({
+  channelSpec,
+  alert,
+  toggleChannel,
+  onChannelPropertyChange,
+  user,
+}: {
+  channelSpec: SlackChannelSpec;
+  alert: Alert;
+  toggleChannel: (channel: "slack", index: number, value: boolean) => void;
+  user: User;
+  onChannelPropertyChange: (index: number, name: string, value: any) => void;
+}) => {
+  const channelIndex = alert.channels.findIndex(
+    channel => channel.channel_type === "slack",
+  );
+  const channel = alert.channels[channelIndex];
+
+  return (
+    <li className={CS.borderRowDivider}>
+      <div className={cx(CS.flex, CS.alignCenter, CS.p3, CS.borderRowDivider)}>
+        <Icon className={cx(CS.mr1, CS.textLight)} name="mail" size={28} />
+
+        <h2>{channelSpec.name}</h2>
+        <Switch
+          className={CS.flexAlignRight}
+          checked={channel?.enabled}
+          onChange={val =>
+            toggleChannel("slack", channelIndex, val.target.checked)
+          }
+        />
+      </div>
+      {channel?.enabled && channelSpec.configured ? (
+        <ul className={cx(CS.bgLight, CS.px3)}>
+          <li className={CS.py2}>
+            <SlackChannelField
+              channel={channel}
+              channelSpec={channelSpec}
+              onChannelPropertyChange={(name: string, value: any) =>
+                onChannelPropertyChange(channelIndex, name, value)
+              }
+            />
+          </li>
+        </ul>
+      ) : channel?.enabled && !channelSpec.configured ? (
+        <div className={cx(CS.p4, CS.textCentered)}>
+          <h3
+            className={CS.mb2}
+          >{t`${channelSpec.name} needs to be set up by an administrator.`}</h3>
+          <ChannelSetupMessage user={user} channels={[channelSpec.name]} />
+        </div>
+      ) : null}
+    </li>
+  );
+};
diff --git a/frontend/src/metabase/pulse/components/WebhookChannelEdit.tsx b/frontend/src/metabase/pulse/components/WebhookChannelEdit.tsx
new file mode 100644
index 00000000000..710450a95a6
--- /dev/null
+++ b/frontend/src/metabase/pulse/components/WebhookChannelEdit.tsx
@@ -0,0 +1,119 @@
+import cx from "classnames";
+import { t } from "ttag";
+
+import { useTestAlertMutation } from "metabase/api";
+import ChannelSetupMessage from "metabase/components/ChannelSetupMessage";
+import { Ellipsified } from "metabase/core/components/Ellipsified";
+import CS from "metabase/css/core/index.css";
+import { useActionButtonLabel } from "metabase/hooks/use-action-button-label";
+import { createChannel } from "metabase/lib/pulse";
+import { Box, Button, Flex, Icon, Switch, Text } from "metabase/ui";
+import type {
+  Alert,
+  ChannelSpec,
+  NotificationChannel,
+  User,
+} from "metabase-types/api";
+
+export const WebhookChannelEdit = ({
+  channelSpec,
+  alert,
+  notification,
+  toggleChannel,
+  user,
+}: {
+  channelSpec: ChannelSpec;
+  alert: Alert;
+  toggleChannel: (
+    channel: "http",
+    index: number,
+    value: boolean,
+    notification: NotificationChannel,
+  ) => void;
+  user: User;
+  notification: NotificationChannel;
+}) => {
+  const [testAlert, testAlertRequest] = useTestAlertMutation();
+  const { label, setLabel } = useActionButtonLabel({
+    defaultLabel: t`Send a test`,
+  });
+
+  const channelIndex = alert.channels.findIndex(
+    channel =>
+      channel.channel_type === "http" && channel.channel_id === notification.id,
+  );
+  const channel = alert.channels[channelIndex];
+
+  const handleTest = async () => {
+    await testAlert({
+      name: notification.name,
+      channels: [
+        createChannel(channelSpec, {
+          channel_id: notification.id,
+        }),
+      ],
+      cards: [alert.card],
+      skip_if_empty: false,
+      alert_condition: "rows",
+    })
+      .unwrap()
+      .then(() => {
+        setLabel(t`Succes`);
+      })
+      .catch(() => {
+        setLabel(t`Something went wrong`);
+      });
+  };
+
+  return (
+    <li className={CS.borderRowDivider} aria-label={notification.name}>
+      <div className={cx(CS.flex, CS.alignCenter, CS.p3, CS.borderRowDivider)}>
+        <Icon className={cx(CS.mr1, CS.textLight)} name="webhook" size={28} />
+
+        <h2>{notification.name}</h2>
+        <Switch
+          className={CS.flexAlignRight}
+          checked={channel?.enabled}
+          onChange={val =>
+            toggleChannel(
+              "http",
+              channelIndex,
+              val.target.checked,
+              notification,
+            )
+          }
+        />
+      </div>
+      {channel?.enabled && channelSpec.configured ? (
+        <ul className={cx(CS.bgLight, CS.px3)}>
+          <li className={CS.py3}>
+            <Flex justify="space-between" gap="5rem" align="center">
+              <Text style={{ flexBasis: 0, flexGrow: 1 }}>
+                <Ellipsified lines={2} multiline tooltipMaxWidth={350}>
+                  {notification.description}
+                </Ellipsified>
+              </Text>
+              <Box>
+                <Button
+                  onClick={handleTest}
+                  disabled={testAlertRequest?.isLoading}
+                >
+                  {label}
+                </Button>
+              </Box>
+            </Flex>
+          </li>
+
+          {/* {renderChannel(channel, channelSpec, channelIndex)} */}
+        </ul>
+      ) : channel?.enabled && !channelSpec.configured ? (
+        <div className={cx(CS.p4, CS.textCentered)}>
+          <h3
+            className={CS.mb2}
+          >{t`${channelSpec.name} needs to be set up by an administrator.`}</h3>
+          <ChannelSetupMessage user={user} channels={[channelSpec.name]} />
+        </div>
+      ) : null}
+    </li>
+  );
+};
diff --git a/frontend/src/metabase/ui/components/icons/Icon/icons/index.ts b/frontend/src/metabase/ui/components/icons/Icon/icons/index.ts
index 3f5331b34b4..6776fda9752 100644
--- a/frontend/src/metabase/ui/components/icons/Icon/icons/index.ts
+++ b/frontend/src/metabase/ui/components/icons/Icon/icons/index.ts
@@ -385,6 +385,8 @@ import warning_component from "./warning.svg?component";
 import warning_source from "./warning.svg?source";
 import waterfall_component from "./waterfall.svg?component";
 import waterfall_source from "./waterfall.svg?source";
+import webhook_component from "./webhook.svg?component";
+import webhook_source from "./webhook.svg?source";
 import zoom_in_component from "./zoom_in.svg?component";
 import zoom_in_source from "./zoom_in.svg?source";
 import zoom_out_component from "./zoom_out.svg?component";
@@ -1157,6 +1159,10 @@ export const Icons = {
     component: waterfall_component,
     source: waterfall_source,
   },
+  webhook: {
+    component: webhook_component,
+    source: webhook_source,
+  },
   "10k": {
     component: ten_thousand_component,
     source: ten_thousand_source,
diff --git a/frontend/src/metabase/ui/components/icons/Icon/icons/webhook.svg b/frontend/src/metabase/ui/components/icons/Icon/icons/webhook.svg
new file mode 100644
index 00000000000..4adeb30d8f1
--- /dev/null
+++ b/frontend/src/metabase/ui/components/icons/Icon/icons/webhook.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M7.56108 6.96296C7.39985 7.23397 7.2405 7.50262 7.08226 7.7694C6.66889 8.46629 6.26308 9.15044 5.85119 9.83085C5.70492 10.0724 5.63254 10.2691 5.74941 10.5762C6.07201 11.4244 5.61692 12.2498 4.76154 12.4739C3.95489 12.6853 3.16895 12.1551 3.0089 11.2915C2.86708 10.527 3.4603 9.77768 4.30309 9.65821C4.34886 9.65167 4.39522 9.64887 4.45448 9.64529L4.45456 9.64528C4.48674 9.64334 4.52273 9.64116 4.56449 9.63803L5.84652 7.48825C5.04019 6.68647 4.56026 5.74917 4.66649 4.5878C4.74158 3.76682 5.06439 3.05739 5.65446 2.47589C6.78458 1.36237 8.50868 1.18203 9.83933 2.03675C11.1174 2.85773 11.7026 4.45694 11.2037 5.82567L10.0323 5.50785C10.1889 4.74688 10.0731 4.06349 9.55981 3.47808C9.22072 3.09157 8.7856 2.88898 8.2908 2.81433C7.29892 2.66448 6.32505 3.30175 6.03609 4.2753C5.70807 5.38024 6.20449 6.28282 7.56108 6.96296ZM9.22436 5.80532L10.4641 7.99178C12.5509 7.34615 14.1243 8.50133 14.6888 9.73812C15.3705 11.2321 14.9045 13.0015 13.5655 13.9232C12.1911 14.8694 10.453 14.7077 9.23532 13.4923L10.1907 12.6928C11.3935 13.4718 12.4453 13.4351 13.2263 12.5127C13.8922 11.7258 13.8778 10.5525 13.1925 9.78207C12.4017 8.89305 11.3425 8.86592 10.062 9.71935C9.91189 9.45295 9.76098 9.18714 9.61014 8.92145L9.61003 8.92126C9.22736 8.24722 8.84508 7.57385 8.47619 6.89312C8.30279 6.57324 8.11127 6.38769 7.72042 6.31998C7.06763 6.2068 6.64618 5.64624 6.6209 5.01819C6.59605 4.39709 6.96195 3.83566 7.5339 3.61691C8.10042 3.40021 8.76526 3.57513 9.14634 4.0568C9.45776 4.45036 9.55672 4.8933 9.39287 5.37866C9.36201 5.4703 9.32497 5.55995 9.28443 5.65809L9.28439 5.65817C9.26508 5.70492 9.24498 5.75359 9.22436 5.80532ZM7.69267 11.722H10.2049C10.2399 11.7687 10.2726 11.8148 10.3046 11.8599C10.3713 11.954 10.4346 12.0433 10.5086 12.1226C11.0406 12.6915 11.9391 12.7197 12.5078 12.1917C13.0972 11.6445 13.1239 10.7252 12.5669 10.1539C12.0219 9.5949 11.0904 9.54152 10.5843 10.1334C10.2769 10.4931 9.96192 10.5355 9.55392 10.5291C8.80835 10.5175 8.06239 10.5197 7.31663 10.522C7.01719 10.5229 6.71778 10.5237 6.41845 10.5237C6.48626 11.9937 5.93059 12.9096 4.82846 13.1271C3.74923 13.3401 2.75529 12.7894 2.40535 11.7847C2.00788 10.6431 2.49942 9.73011 3.9197 9.00548C3.81282 8.61832 3.70485 8.2265 3.59797 7.83836C2.04998 8.17571 0.888607 9.67759 1.00851 11.3672C1.11441 12.8587 2.31756 14.1833 3.78895 14.4206C4.58812 14.5496 5.339 14.4156 6.03563 14.0204C6.9318 13.512 7.45189 12.7124 7.69267 11.722Z" fill="#4C5773"/>
+</svg>
\ No newline at end of file
diff --git a/frontend/src/metabase/ui/components/inputs/Chip/Chip.styled.tsx b/frontend/src/metabase/ui/components/inputs/Chip/Chip.styled.tsx
new file mode 100644
index 00000000000..aa66b130182
--- /dev/null
+++ b/frontend/src/metabase/ui/components/inputs/Chip/Chip.styled.tsx
@@ -0,0 +1,36 @@
+import { type MantineThemeOverride, rem } from "@mantine/core";
+
+export const getChipOverrides = (): MantineThemeOverride["components"] => ({
+  Chip: {
+    defaultProps: {
+      size: 14,
+    },
+    variants: {
+      brand: (_theme, _props, context) => {
+        return {
+          iconWrapper: {
+            display: "none",
+          },
+
+          label: {
+            backgroundColor: "var(--mb-color-brand-light)",
+            color: "var(--mb-color-brand)",
+            padding: "0.5rem 1rem ",
+            display: "block",
+            height: "auto",
+            lineHeight: `calc(${rem(context.size)})`,
+
+            "&[data-checked=true]": {
+              backgroundColor: "var(--mb-color-brand)",
+              color: "white",
+              paddingInline: "1rem",
+            },
+          },
+          input: {
+            display: "block",
+          },
+        };
+      },
+    },
+  },
+});
diff --git a/frontend/src/metabase/ui/components/inputs/Chip/index.ts b/frontend/src/metabase/ui/components/inputs/Chip/index.ts
new file mode 100644
index 00000000000..f11074f772f
--- /dev/null
+++ b/frontend/src/metabase/ui/components/inputs/Chip/index.ts
@@ -0,0 +1,3 @@
+export { Chip } from "@mantine/core";
+export type { ChipProps, ChipGroupProps } from "@mantine/core";
+export { getChipOverrides } from "./Chip.styled";
diff --git a/frontend/src/metabase/ui/components/inputs/index.ts b/frontend/src/metabase/ui/components/inputs/index.ts
index 2b607854621..1cb7d6a9353 100644
--- a/frontend/src/metabase/ui/components/inputs/index.ts
+++ b/frontend/src/metabase/ui/components/inputs/index.ts
@@ -1,6 +1,7 @@
 export * from "./Autocomplete";
 export * from "./Calendar";
 export * from "./Checkbox";
+export * from "./Chip";
 export * from "./DateInput";
 export * from "./DatePicker";
 export * from "./FileInput";
diff --git a/frontend/src/metabase/ui/theme.ts b/frontend/src/metabase/ui/theme.ts
index 353f8c4c2a3..d30e9dfcbf4 100644
--- a/frontend/src/metabase/ui/theme.ts
+++ b/frontend/src/metabase/ui/theme.ts
@@ -13,6 +13,7 @@ import {
   getCalendarOverrides,
   getCardOverrides,
   getCheckboxOverrides,
+  getChipOverrides,
   getDateInputOverrides,
   getDatePickerOverrides,
   getDividerOverrides,
@@ -120,6 +121,7 @@ export const getThemeOverrides = (): MantineThemeOverride => ({
     ...getCalendarOverrides(),
     ...getCardOverrides(),
     ...getCheckboxOverrides(),
+    ...getChipOverrides(),
     ...getDateInputOverrides(),
     ...getDatePickerOverrides(),
     ...getDividerOverrides(),
diff --git a/resources/migrations/001_update_migrations.yaml b/resources/migrations/001_update_migrations.yaml
index a17c46fa1cc..4351d757369 100644
--- a/resources/migrations/001_update_migrations.yaml
+++ b/resources/migrations/001_update_migrations.yaml
@@ -8602,6 +8602,113 @@ databaseChangeLog:
             indexName: idx_user_id_device_id
       preConditions:
 
+  - changeSet:
+      id: v51.2024-07-08T10:00:00
+      author: qnkhuat
+      comment: Create channel table
+      preConditions:
+        - onFail: MARK_RAN
+        - not:
+            - tableExists:
+                tableName: channel
+      changes:
+        - createTable:
+            tableName: channel
+            remarks: Channel configurations
+            columns:
+              - column:
+                  name: id
+                  remarks: Unique ID
+                  type: int
+                  autoIncrement: true
+                  constraints:
+                    primaryKey: true
+                    nullable: false
+              - column:
+                  name: name
+                  remarks: channel name
+                  type: varchar(254)
+                  constraints:
+                    nullable: false
+                    unique: true
+              - column:
+                  name: description
+                  remarks: channel description
+                  type: ${text.type}
+                  constraints:
+                    nullable: true
+              - column:
+                  name: type
+                  remarks: Channel type
+                  type: varchar(32)
+                  constraints:
+                    nullable: false
+              - column:
+                  name: details
+                  remarks: Channel details, used to store authentication information or channel-specific settings
+                  type: ${text.type}
+                  constraints:
+                    nullable: false
+              - column:
+                  name: active
+                  type: ${boolean.type}
+                  defaultValueBoolean: true
+                  remarks: whether the channel is active
+                  constraints:
+                    nullable: false
+              - column:
+                  name: created_at
+                  remarks: Timestamp when the channel was inserted
+                  type: ${timestamp_type}
+                  constraints:
+                    nullable: false
+              - column:
+                  name: updated_at
+                  remarks: Timestamp when the channel was updated
+                  type: ${timestamp_type}
+                  constraints:
+                    nullable: false
+
+  - changeSet:
+      id: v51.2024-07-08T10:00:01
+      author: qnkhuat
+      comment: Create pulse_channel.channel_id
+      preConditions:
+        - onFail: MARK_RAN
+        - not:
+            - columnExists:
+                tableName: pulse_channel
+                columnName: channel_id
+      changes:
+        - addColumn:
+            tableName: pulse_channel
+            columns:
+              - column:
+                  name: channel_id
+                  type: integer
+                  remarks: The channel ID
+
+  - changeSet:
+      id: v51.2024-07-08T10:00:02
+      author: qnkhuat
+      comment: Add fk constraint to pulse_channel.channel_id
+      preConditions:
+        - onFail: MARK_RAN
+        - not:
+            - foreignKeyConstraintExists:
+                foreignKeyName: fk_pulse_channel_channel_id
+                foreignKyeTableName: pulse_channel
+
+      changes:
+        - addForeignKeyConstraint:
+            baseTableName: pulse_channel
+            baseColumnNames: channel_id
+            referencedTableName: channel
+            referencedColumnNames: id
+            constraintName: fk_pulse_channel_channel_id
+            nullable: true
+            deleteCascade: true
+
   - changeSet:
       id: v51.2024-07-09T17:15:43
       author: tsmacdonald
diff --git a/src/metabase/api/channel.clj b/src/metabase/api/channel.clj
new file mode 100644
index 00000000000..7bb70776055
--- /dev/null
+++ b/src/metabase/api/channel.clj
@@ -0,0 +1,102 @@
+(ns ^{:added "0.51.0"} metabase.api.channel
+  "/api/channel endpoints.
+
+  Currently only used for http channels."
+  (:require
+   [compojure.core :refer [DELETE GET POST PUT]]
+   [metabase.api.common :as api]
+   [metabase.api.common.validation :as validation]
+   [metabase.channel.core :as channel]
+   [metabase.events :as events]
+   [metabase.models.interface :as mi]
+   [metabase.util :as u]
+   [metabase.util.i18n :refer [deferred-tru]]
+   [metabase.util.malli :as mu]
+   [metabase.util.malli.schema :as ms]
+   [toucan2.core :as t2]))
+
+(defn- remove-details-if-needed
+  "Remove the details field if the current user does not have write permissions for the channel."
+  [channel]
+  (if (mi/can-write? channel)
+    channel
+    (dissoc channel :details)))
+
+(api/defendpoint GET "/"
+  "Get all channels"
+  [:as {{:keys [include_inactive]} :body}]
+  {include_inactive [:maybe {:default false} :boolean]}
+  (map remove-details-if-needed (if include_inactive
+                                  (t2/select :model/Channel)
+                                  (t2/select :model/Channel :active true))))
+
+(defn- test-channel-connection!
+  "Test if a channel can be connected, throw an exception if it fails."
+  [type details]
+  (try
+    (let [result (channel/can-connect? type details)]
+      (when-not (true? result)
+        (throw (ex-info "Unable to connect channel" (merge {:status-code 400} result)))))
+    (catch Exception e
+      (throw (ex-info "Unable to connect channel" (merge {:status-code 400} (ex-data e)))))))
+
+(def ^:private ChannelType
+  (mu/with-api-error-message
+   [:fn {:decode/string keyword}
+    #(= "channel" (namespace (keyword %)))]
+   (deferred-tru "Must be a namespaced channel. E.g: channel/http")))
+
+(api/defendpoint POST "/"
+  "Create a channel"
+  [:as {{:keys [name description type active details] :as body} :body}]
+  {name        ms/NonBlankString
+   description [:maybe ms/NonBlankString]
+   type        ChannelType
+   details     :map
+   active      [:maybe {:default true} :boolean]}
+  (validation/check-has-application-permission :setting)
+  (when (t2/exists? :model/Channel :name name)
+    (throw (ex-info "Channel with that name already exists" {:status-code 409
+                                                             :errors      {:name "Channel with that name already exists"}})))
+  (test-channel-connection! type details)
+  (u/prog1 (t2/insert-returning-instance! :model/Channel body)
+    (events/publish-event! :event/channel-create {:object <> :user-id api/*current-user-id*})))
+
+(api/defendpoint GET "/:id"
+  "Get a channel"
+  [id]
+  {id ms/PositiveInt}
+  (-> (t2/select-one :model/Channel id) api/check-404 remove-details-if-needed))
+
+(api/defendpoint PUT "/:id"
+  "Update a channel"
+  [id :as {{:keys [name type description details active] :as body} :body}]
+  {id          ms/PositiveInt
+   name        [:maybe ms/NonBlankString]
+   description [:maybe ms/NonBlankString]
+   type        [:maybe ChannelType]
+   details     [:maybe :map]
+   active      [:maybe :boolean]}
+  (validation/check-has-application-permission :setting)
+  (let [channel-before-update (api/check-404 (t2/select-one :model/Channel id))
+        details-changed? (some-> details (not= (:details channel-before-update)))
+        type-changed?    (some-> type (not= (:type channel-before-update)))]
+
+    (when (or details-changed? type-changed?)
+      (test-channel-connection! (or type (:type channel-before-update))
+                                (or details (:details channel-before-update))))
+    (t2/update! :model/Channel id body)
+    (u/prog1 (t2/select-one :model/Channel id)
+      (events/publish-event! :event/channel-update {:object          <>
+                                                    :user-id         api/*current-user-id*
+                                                    :previous-object channel-before-update}))))
+
+(api/defendpoint POST "/test"
+  "Test a channel connection"
+  [:as {{:keys [type details]} :body}]
+  {type    ChannelType
+   details :map}
+  (test-channel-connection! type details)
+  {:ok true})
+
+(api/define-routes)
diff --git a/src/metabase/api/pulse.clj b/src/metabase/api/pulse.clj
index 6b6e772aac0..81e0aa29e76 100644
--- a/src/metabase/api/pulse.clj
+++ b/src/metabase/api/pulse.clj
@@ -229,7 +229,8 @@
   (validation/check-has-application-permission :subscription false)
   (let [chan-types (-> channel-types
                        (assoc-in [:slack :configured] (slack/slack-configured?))
-                       (assoc-in [:email :configured] (email/email-configured?)))]
+                       (assoc-in [:email :configured] (email/email-configured?))
+                       (assoc-in [:http :configured] (t2/exists? :model/Channel :type :channel/http :active true)))]
     {:channels (cond
                  (premium-features/sandboxed-or-impersonated-user?)
                  (dissoc chan-types :slack)
diff --git a/src/metabase/api/routes.clj b/src/metabase/api/routes.clj
index 86f101abfb9..4f4a0bea4a7 100644
--- a/src/metabase/api/routes.clj
+++ b/src/metabase/api/routes.clj
@@ -10,6 +10,7 @@
    [metabase.api.bookmark :as api.bookmark]
    [metabase.api.cache :as api.cache]
    [metabase.api.card :as api.card]
+   [metabase.api.channel :as api.channel]
    [metabase.api.cloud-migration :as api.cloud-migration]
    [metabase.api.collection :as api.collection]
    [metabase.api.common :as api :refer [defroutes context]]
@@ -109,6 +110,7 @@
   (context "/card"                 [] (+auth api.card/routes))
   (context "/cloud-migration"      [] (+auth api.cloud-migration/routes))
   (context "/collection"           [] (+auth api.collection/routes))
+  (context "/channel"              [] (+auth api.channel/routes))
   (context "/dashboard"            [] (+auth api.dashboard/routes))
   (context "/database"             [] (+auth api.database/routes))
   (context "/dataset"              [] (+auth api.dataset/routes))
diff --git a/src/metabase/channel/core.clj b/src/metabase/channel/core.clj
index a3b9945dbc3..c2e95d7473f 100644
--- a/src/metabase/channel/core.clj
+++ b/src/metabase/channel/core.clj
@@ -13,6 +13,21 @@
 ;;                                      Channels methods                                           ;;
 ;; ------------------------------------------------------------------------------------------------;;
 
+(defmulti can-connect?
+  "Check whether we can connect to a `channel-type` with `detail`.
+
+  Returns `true` if can connect to the channel, otherwise return falsy or throw an appropriate exception.
+  In case of failure, to provide a field-specific error message on UI, return or throw an :errors map where key is the
+  field name and value is the error message.
+
+  E.g:
+    (can-connect? :slack {:email \"name\"})
+    ;; => {:errors {:email \"Invalid email\"}}"
+  {:added    "0.51.0"
+   :arglists '([channel-type details])}
+  (fn [channel-type _details]
+    channel-type))
+
 (defmulti render-notification
   "Given a notification content, return a sequence of channel-specific messages.
 
@@ -26,21 +41,18 @@
 (defmulti send!
   "Send a message to a channel."
   {:added    "0.51.0"
-   :arglists '([channel-type message])}
-  (fn [channel-type _message]
-    channel-type))
+   :arglists '([channel message])}
+  (fn [channel _message]
+    (:type channel)))
 
 ;; ------------------------------------------------------------------------------------------------;;
 ;;                                             Utils                                               ;;
 ;; ------------------------------------------------------------------------------------------------;;
 
-(defn- find-and-load-metabase-channels!
+(defn find-and-load-metabase-channels!
   "Load namespaces that start with `metabase.channel."
   []
   (doseq [ns-symb u/metabase-namespace-symbols
           :when   (.startsWith (name ns-symb) "metabase.channel.")]
     (log/info "Loading channel namespace:" (u/format-color :blue ns-symb))
     (classloader/require ns-symb)))
-
-(when-not *compile-files*
-  (find-and-load-metabase-channels!))
diff --git a/src/metabase/channel/email.clj b/src/metabase/channel/email.clj
index 7789633f82d..75b054076a4 100644
--- a/src/metabase/channel/email.clj
+++ b/src/metabase/channel/email.clj
@@ -47,7 +47,7 @@
     nil))
 
 (mu/defmethod channel/send! :channel/email
-  [_channel-type {:keys [subject recipients message-type message]} :- EmailMessage]
+  [_channel {:keys [subject recipients message-type message]} :- EmailMessage]
   (email/send-message-or-throw! {:subject      subject
                                  :recipients   recipients
                                  :message-type message-type
@@ -55,7 +55,7 @@
                                  :bcc?         (email/bcc-enabled?)}))
 
 (mu/defmethod channel/render-notification [:channel/email :notification/alert] :- [:sequential EmailMessage]
-  [_channel-type {:keys [card pulse payload channel]} recipients]
+  [_channel-type {:keys [card pulse payload pulse-channel]} recipients]
   (let [condition-kwd             (messages/pulse->alert-condition-kwd pulse)
         email-subject             (case condition-kwd
                                     :meets (trs "Alert: {0} has reached its goal" (:name card))
@@ -68,14 +68,14 @@
         email-to-users            (when (> (count user-emails) 0)
                                     (construct-pulse-email
                                      email-subject user-emails
-                                     (messages/render-alert-email timezone pulse channel
+                                     (messages/render-alert-email timezone pulse pulse-channel
                                                                   [payload]
                                                                   goal
                                                                   nil)))
         email-to-nonusers         (for [non-user-email non-user-emails]
                                     (construct-pulse-email
                                      email-subject [non-user-email]
-                                     (messages/render-alert-email timezone pulse channel
+                                     (messages/render-alert-email timezone pulse pulse-channel
                                                                   [payload]
                                                                   goal
                                                                   non-user-email)))]
@@ -86,7 +86,7 @@
 ;; ------------------------------------------------------------------------------------------------;;
 
 (mu/defmethod channel/render-notification [:channel/email :notification/dashboard-subscription] :- [:sequential EmailMessage]
-  [_channel-details {:keys [dashboard payload pulse]} recipients]
+  [_channel-type {:keys [dashboard payload pulse]} recipients]
   (let [{:keys [user-emails
                 non-user-emails]} (recipients->emails recipients)
         timezone                  (some->> payload (some :card) channel.shared/defaulted-timezone)
diff --git a/src/metabase/channel/http.clj b/src/metabase/channel/http.clj
new file mode 100644
index 00000000000..aba212dd81b
--- /dev/null
+++ b/src/metabase/channel/http.clj
@@ -0,0 +1,93 @@
+(ns metabase.channel.http
+  (:require
+   [cheshire.core :as json]
+   [clj-http.client :as http]
+   [java-time.api :as t]
+   [metabase.channel.core :as channel]
+   [metabase.channel.shared :as channel.shared]
+   [metabase.pulse.render :as render]
+   [metabase.util.i18n :refer [tru]]
+   [metabase.util.malli :as mu]
+   [metabase.util.malli.schema :as ms]
+   [metabase.util.urls :as urls]))
+
+(def ^:private image-width
+  "Maximum width of the rendered PNG of HTML to be sent to HTTP Content that exceeds this width (e.g. a table with
+  many columns) is truncated."
+  1200)
+
+(def ^:private string-or-keyword
+  [:or :string :keyword])
+
+(def ^:private HTTPDetails
+  [:map {:closed true}
+   [:url                           ms/Url]
+   [:auth-method                   [:enum "none" "header" "query-param" "request-body"]]
+   [:auth-info    {:optional true} [:map-of string-or-keyword :any]]
+   ;; used by the frontend to display the auth info properly
+   [:fe-form-type {:optional true} [:enum "api-key" "bearer" "basic" "none"]]
+   ;; request method
+   [:method       {:optional true} [:enum "get" "post" "put"]]])
+
+(def ^:private HTTPChannel
+  [:map
+   [:type    [:= :channel/http]]
+   [:details HTTPDetails]])
+
+(mu/defmethod channel/send! :channel/http
+  [{{:keys [url method auth-method auth-info]} :details} :- HTTPChannel
+   request]
+  (let [req (merge
+             {:accept       :json
+              :content-type :json
+              :method       :post
+              :url          url}
+             (when method
+               {:method (keyword method)})
+             (cond-> request
+               (= "request-body" auth-method) (update :body merge auth-info)
+               (= "header" auth-method)       (update :headers merge auth-info)
+               (= "query-param" auth-method)  (update :query-params merge auth-info)))]
+    (http/request (cond-> req
+                    (map? (:body req)) (update :body json/generate-string)))))
+
+(defmethod channel/can-connect? :channel/http
+  [_channel-type details]
+  (channel.shared/validate-channel-details HTTPDetails details)
+  (try
+    (channel/send! {:type :channel/http :details details} {})
+    true
+    (catch Exception e
+      (let [data (ex-data e)]
+        ;; throw an appriopriate error if it's a connection error
+        (if (= ::http/unexceptional-status (:type data))
+          (throw (ex-info (tru "Failed to connect to channel") {:request-status (:status data)
+                                                                :request-body   (:body data)}))
+          (throw e))))))
+
+;; ------------------------------------------------------------------------------------------------;;
+;;                                           Alerts                                                ;;
+;; ------------------------------------------------------------------------------------------------;;
+
+(defn- qp-result->raw-data
+  [qp-result]
+  (let [data (:data qp-result)]
+    {:cols (map :name (:cols data))
+     :rows (:rows data)}))
+
+(mu/defmethod channel/render-notification [:channel/http :notification/alert]
+  [_channel-type {:keys [card pulse payload]} _recipients]
+  (let [request-body      {:type               "alert"
+                           :alert_id           (:id pulse)
+                           :alert_creator_id   (get-in pulse [:creator :id])
+                           :alert_creator_name (get-in pulse [:creator :common_name])
+                           :data               {:type          "question"
+                                                :question_id   (:id card)
+                                                :question_name (:name card)
+                                                :question_url  (urls/card-url (:id card))
+                                                :visualization (let [{:keys [card dashcard result]} payload]
+                                                                 (render/render-pulse-card-to-base64
+                                                                  (channel.shared/defaulted-timezone card) card dashcard result image-width))
+                                                :raw_data      (qp-result->raw-data (:result payload))}
+                           :sent_at            (t/offset-date-time)}]
+    [{:body request-body}]))
diff --git a/src/metabase/channel/shared.clj b/src/metabase/channel/shared.clj
index 1b5700c33d6..0cae0274e90 100644
--- a/src/metabase/channel/shared.clj
+++ b/src/metabase/channel/shared.clj
@@ -1,6 +1,9 @@
 (ns metabase.channel.shared
   (:require
+   [malli.core :as mc]
+   [malli.error :as me]
    [metabase.query-processor.timezone :as qp.timezone]
+   [metabase.util.i18n :refer [tru]]
    [metabase.util.malli :as mu]
    [toucan2.core :as t2]))
 
@@ -9,3 +12,11 @@
   [card]
   (or (some->> card :database_id (t2/select-one :model/Database :id) qp.timezone/results-timezone-id)
       (qp.timezone/system-timezone-id)))
+
+(defn validate-channel-details
+  "Validate a value against a schema and throw an exception if it's invalid.
+  The :errors key are used on the UI to display field-specific error messages."
+  [schema value]
+  (when-let [errors (some-> (mc/explain schema value)
+                            me/humanize)]
+    (throw (ex-info (tru "Invalid channel details") {:errors errors}))))
diff --git a/src/metabase/channel/slack.clj b/src/metabase/channel/slack.clj
index dc16902ce5e..ddb5b09fae0 100644
--- a/src/metabase/channel/slack.clj
+++ b/src/metabase/channel/slack.clj
@@ -87,7 +87,7 @@
    [:message     {:optional true} [:maybe :string]]])
 
 (mu/defmethod channel/send! :channel/slack
-  [_channel-type message :- SlackMessage]
+  [_channel message :- SlackMessage]
   (let [{:keys [channel-id attachments]} message]
     (slack/post-chat-message! channel-id nil (create-and-upload-slack-attachments! attachments))))
 
@@ -96,7 +96,7 @@
 ;; ------------------------------------------------------------------------------------------------;;
 
 (mu/defmethod channel/render-notification [:channel/slack :notification/alert] :- [:sequential SlackMessage]
-  [_channel-details {:keys [payload card]} channel-ids]
+  [_channel-type {:keys [payload card]} channel-ids]
   (let [attachments [{:blocks [{:type "header"
                                 :text {:type "plain_text"
                                        :text (str "🔔 " (:name card))
diff --git a/src/metabase/cmd/copy.clj b/src/metabase/cmd/copy.clj
index 1651e9e51f3..e313a698e9a 100644
--- a/src/metabase/cmd/copy.clj
+++ b/src/metabase/cmd/copy.clj
@@ -46,7 +46,8 @@
   "Entities in the order they should be serialized/deserialized. This is done so we make sure that we load
   instances of entities before others that might depend on them, e.g. `Databases` before `Tables` before `Fields`."
   (concat
-   [:model/Database
+   [:model/Channel
+    :model/Database
     :model/User
     :model/Setting
     :model/Table
diff --git a/src/metabase/core.clj b/src/metabase/core.clj
index 7c05d914eeb..42c371e2585 100644
--- a/src/metabase/core.clj
+++ b/src/metabase/core.clj
@@ -4,6 +4,7 @@
    [clojure.tools.trace :as trace]
    [java-time.api :as t]
    [metabase.analytics.prometheus :as prometheus]
+   [metabase.channel.core :as channel]
    [metabase.config :as config]
    [metabase.core.config-from-file :as config-from-file]
    [metabase.core.initialization-status :as init-status]
@@ -158,6 +159,8 @@
   (settings/migrate-encrypted-settings!)
   ;; start scheduler at end of init!
   (task/start-scheduler!)
+  ;; load the channels
+  (channel/find-and-load-metabase-channels!)
   (init-status/set-complete!)
   (let [start-time (.getStartTime (ManagementFactory/getRuntimeMXBean))
         duration   (- (System/currentTimeMillis) start-time)]
diff --git a/src/metabase/events/audit_log.clj b/src/metabase/events/audit_log.clj
index bf79071e5ce..37a9d82b7c9 100644
--- a/src/metabase/events/audit_log.clj
+++ b/src/metabase/events/audit_log.clj
@@ -223,3 +223,11 @@
 (methodical/defmethod events/publish-event! ::cache-config-changed-event
   [topic event]
   (audit-log/record-event! topic event))
+
+(derive ::channel-event ::event)
+(derive :event/channel-create ::channel-event)
+(derive :event/channel-update ::channel-event)
+
+(methodical/defmethod events/publish-event! ::channel-event
+  [topic event]
+  (audit-log/record-event! topic event))
diff --git a/src/metabase/models.clj b/src/metabase/models.clj
index e87a78a37d1..281f2ae15ea 100644
--- a/src/metabase/models.clj
+++ b/src/metabase/models.clj
@@ -5,6 +5,7 @@
    [metabase.models.bookmark :as bookmark]
    [metabase.models.cache-config :as cache-config]
    [metabase.models.card :as card]
+   [metabase.models.channel :as models.channel]
    [metabase.models.collection :as collection]
    [metabase.models.collection-permission-graph-revision
     :as c-perm-revision]
@@ -79,6 +80,7 @@
          legacy-metric-important-field/keep-me
          login-history/keep-me
          moderation-review/keep-me
+         models.channel/keep-me
          native-query-snippet/keep-me
          parameter-card/keep-me
          perms-group-membership/keep-me
diff --git a/src/metabase/models/channel.clj b/src/metabase/models/channel.clj
new file mode 100644
index 00000000000..32ac7d8e2eb
--- /dev/null
+++ b/src/metabase/models/channel.clj
@@ -0,0 +1,67 @@
+(ns ^{:added "0.51.0"} metabase.models.channel
+  (:require
+   [metabase.models.audit-log :as audit-log]
+   [metabase.models.interface :as mi]
+   [metabase.models.permissions :as perms]
+   [metabase.models.serialization :as serdes]
+   [metabase.util :as u]
+   [methodical.core :as methodical]
+   [toucan2.core :as t2]))
+
+(set! *warn-on-reflection* true)
+
+(methodical/defmethod t2/table-name :model/Channel [_model] :channel)
+
+(defmethod mi/can-write? :model/Channel
+  [& _]
+  (or (mi/superuser?)
+      (perms/current-user-has-application-permissions? :setting)))
+
+(defmethod serdes/entity-id "Channel"
+  [_ {:keys [name]}]
+  name)
+
+(defmethod serdes/hash-fields :model/Channel
+  [_database]
+  [:name :type])
+
+(defmethod serdes/make-spec "Channel"
+  [_model-name _opts]
+  {:copy      [:name :description :type :details :active]
+   :transform {:created_at (serdes/date)}})
+
+(doto :model/Channel
+  (derive :metabase/model)
+  (derive :hook/timestamped?))
+
+(t2/deftransforms :model/Channel
+  {:type    mi/transform-keyword
+   :details mi/transform-encrypted-json})
+
+(defn- assert-channel-type
+  [{channel-type :type}]
+  (when-not (= "channel" (-> channel-type keyword namespace))
+    (throw (ex-info "Channel type must be a namespaced keyword like :channel/http" {:status-code  400
+                                                                                    :channel-type channel-type}))))
+
+(t2/define-before-insert :model/Channel
+  [instance]
+  (assert-channel-type instance)
+  instance)
+
+(t2/define-before-update :model/Channel
+  [instance]
+  (let [deactivation? (false? (:active (t2/changes instance)))]
+    (assert-channel-type instance)
+    (when deactivation?
+      (t2/delete! :model/PulseChannel :channel_id (:id instance)))
+    (cond-> instance
+      deactivation?
+      ;; Channel.name has an unique constraint and it's a useful property for serialization
+      ;; We rename deactivated channels so that new channels can reuse the name
+      ;; Limit to 254 characters to avoid hitting character limit
+      (assoc :name (u/truncate (format "DEACTIVATED_%d %s" (:id instance) (:name instance)) 254)))))
+
+(defmethod audit-log/model-details :model/Channel
+  [channel _event-type]
+  (select-keys channel [:id :name :description :type :active]))
diff --git a/src/metabase/models/permissions.clj b/src/metabase/models/permissions.clj
index a9b2ca85341..80e755f7b12 100644
--- a/src/metabase/models/permissions.clj
+++ b/src/metabase/models/permissions.clj
@@ -175,7 +175,7 @@
    [metabase.models.permissions-group :as perms-group]
    [metabase.permissions.util :as perms.u]
    [metabase.plugins.classloader :as classloader]
-   [metabase.public-settings.premium-features :as premium-features]
+   [metabase.public-settings.premium-features :as premium-features :refer [defenterprise]]
    [metabase.util :as u]
    [metabase.util.honey-sql-2 :as h2x]
    [metabase.util.i18n :refer [tru]]
@@ -520,3 +520,10 @@
   [group-or-id :- MapOrID collection-or-id :- MapOrID]
   (check-is-modifiable-collection collection-or-id)
   (grant-permissions! (u/the-id group-or-id) (collection-read-path collection-or-id)))
+
+(defenterprise current-user-has-application-permissions?
+  "Check if `*current-user*` has permissions for a application permissions of type `perm-type`.
+  This is a paid feature so it's `false` for OSS instances."
+  metabase-enterprise.advanced-permissions.common
+  [_instance]
+  false)
diff --git a/src/metabase/models/pulse.clj b/src/metabase/models/pulse.clj
index 8cd5a1a9ce1..b94d120c8db 100644
--- a/src/metabase/models/pulse.clj
+++ b/src/metabase/models/pulse.clj
@@ -450,54 +450,45 @@
                              card-refs)]
       (t2/insert! PulseCard cards))))
 
-(defn- create-update-delete-channel!
-  "Utility function used by [[update-notification-channels!]] which determines how to properly update a single pulse
-  channel."
-  [notification-or-id new-channel existing-channel]
-  ;; NOTE that we force the :id of the channel being updated to the :id we *know* from our
-  ;;      existing list of PulseChannels pulled from the db to ensure we affect the right record
-  (let [channel (when new-channel
-                  (assoc new-channel
-                         :pulse_id       (u/the-id notification-or-id)
-                         :id             (:id existing-channel)
-                         :enabled        (:enabled new-channel)
-                         :channel_type   (keyword (:channel_type new-channel))
-                         :schedule_type  (keyword (:schedule_type new-channel))
-                         :schedule_frame (keyword (:schedule_frame new-channel))))]
-    (cond
-      ;; 1. in channels, NOT in db-channels = CREATE
-      (and channel (not existing-channel))  (pulse-channel/create-pulse-channel! channel)
-      ;; 2. NOT in channels, in db-channels = DELETE
-      (and (nil? channel) existing-channel) (t2/delete! PulseChannel :id (:id existing-channel))
-      ;; 3. in channels, in db-channels = UPDATE
-      (and channel existing-channel)        (pulse-channel/update-pulse-channel! channel)
-      ;; 4. NOT in channels, NOT in db-channels = NO-OP
-      :else                                 nil)))
-
 (mu/defn update-notification-channels!
   "Update the PulseChannels for a given `notification-or-id`. `channels` should be a definitive collection of *all* of
   the channels for the Notification.
 
-   * If a channel in the list has no existing `PulseChannel` object, one will be created.
+    * If a channel in the list has no existing `PulseChannel` object, one will be created.
 
-   * If an existing `PulseChannel` has no corresponding entry in `channels`, it will be deleted.
+    * If an existing `PulseChannel` has no corresponding entry in `channels`, it will be deleted.
 
-   * All previously existing channels will be updated with their most recent information."
+    * All previously existing channels will be updated with their most recent information."
   [notification-or-id channels :- [:sequential :map]]
-  (let [new-channels   (group-by (comp keyword :channel_type) channels)
-        old-channels   (group-by (comp keyword :channel_type) (t2/select PulseChannel
-                                                                         :pulse_id (u/the-id notification-or-id)))
-        handle-channel #(create-update-delete-channel! (u/the-id notification-or-id)
-                                                       (first (get new-channels %))
-                                                       (first (get old-channels %)))]
-    (assert (zero? (count (get new-channels nil)))
-            "Cannot have channels without a :channel_type attribute")
-    ;; don't automatically archive this Pulse if we end up deleting its last PulseChannel -- we're probably replacing
-    ;; it with a new one immediately thereafter.
+  (let [existing-channels   (t2/select :model/PulseChannel :pulse_id (u/the-id notification-or-id))
+        channels            (map-indexed
+                             (fn [idx channel]
+                               (assoc channel
+                                      :channel_type   (keyword (:channel_type channel))
+                                      :schedule_type  (keyword (:schedule_type channel))
+                                      :schedule_frame (keyword (:schedule_frame channel))
+                                      :pulse_id       (u/the-id notification-or-id)
+                                      ;; for "new channels" we assign it with an negative id so that
+                                      ;; row-diff will treat it as :to-create
+                                      :id             (or
+                                                       (:id channel)
+                                                       ;; new channel
+                                                       (- (inc idx)))))
+                             channels)
+        {:keys [to-create
+                to-update
+                to-delete]} (u/row-diff existing-channels
+                                        channels
+                                        {:to-compare #(dissoc % :created_at :updated_at)})]
+    (doseq [channel to-create]
+      (pulse-channel/create-pulse-channel! channel))
+    (doseq [channel to-update]
+      (assert (:id channel) "Cannot update a PulseChannel without an :id")
+      (pulse-channel/update-pulse-channel! channel))
     (binding [pulse-channel/*archive-parent-pulse-when-last-channel-is-deleted* false]
-      ;; for each of our possible channel types call our handler function
-      (doseq [[channel-type] pulse-channel/channel-types]
-        (handle-channel channel-type)))))
+      (when (seq to-delete)
+        (assert (every? :id to-delete) "Cannot delete a PulseChannel without an :id")
+        (t2/delete! PulseChannel :id [:in (map :id to-delete)])))))
 
 (mu/defn- create-notification-and-add-cards-and-channels!
   "Create a new Pulse/Alert with the properties specified in `notification`; add the `card-refs` to the Notification and
diff --git a/src/metabase/models/pulse_channel.clj b/src/metabase/models/pulse_channel.clj
index edff5c2d4ac..4cf4b9bba92 100644
--- a/src/metabase/models/pulse_channel.clj
+++ b/src/metabase/models/pulse_channel.clj
@@ -93,7 +93,11 @@
                                 :type        "select"
                                 :displayName "Post to"
                                 :options     []
-                                :required    true}]}})
+                                :required    true}]}
+   :http  {:type              "http"
+           :name              "Webhook"
+           :allows_recipients false
+           :schedules         [:hourly :daily :weekly :monthly]}})
 
 (defn channel-type?
   "Is `channel-type` a valid value as a channel type? :tv:"
@@ -296,7 +300,7 @@
 (defn create-pulse-channel!
   "Create a new `PulseChannel` along with all related data associated with the channel such as
   `PulseChannelRecipients`."
-  [{:keys [channel_type details enabled pulse_id recipients schedule_type schedule_day schedule_hour schedule_frame]
+  [{:keys [channel_type channel_id details enabled pulse_id recipients schedule_type schedule_day schedule_hour schedule_frame]
     :or   {details          {}
            recipients       []}}]
   {:pre [(channel-type? channel_type)
@@ -307,20 +311,21 @@
          (coll? recipients)
          (every? map? recipients)]}
   (let [recipients-by-type (group-by integer? (filter identity (map #(or (:id %) (:email %)) recipients)))
-        {:keys [id]}       (first (t2/insert-returning-instances!
-                                   PulseChannel
-                                   :pulse_id       pulse_id
-                                   :channel_type   channel_type
-                                   :details        (cond-> details
-                                                     (supports-recipients? channel_type) (assoc :emails (get recipients-by-type false)))
-                                   :enabled        enabled
-                                   :schedule_type  schedule_type
-                                   :schedule_hour  (when (not= schedule_type :hourly)
-                                                     schedule_hour)
-                                   :schedule_day   (when (contains? #{:weekly :monthly} schedule_type)
-                                                     schedule_day)
-                                   :schedule_frame (when (= schedule_type :monthly)
-                                                     schedule_frame)))]
+        id                 (t2/insert-returning-pk!
+                            PulseChannel
+                            :pulse_id       pulse_id
+                            :channel_type   channel_type
+                            :channel_id     channel_id
+                            :details        (cond-> details
+                                              (supports-recipients? channel_type) (assoc :emails (get recipients-by-type false)))
+                            :enabled        enabled
+                            :schedule_type  schedule_type
+                            :schedule_hour  (when (not= schedule_type :hourly)
+                                              schedule_hour)
+                            :schedule_day   (when (contains? #{:weekly :monthly} schedule_type)
+                                              schedule_day)
+                            :schedule_frame (when (= schedule_type :monthly)
+                                              schedule_frame))]
     (when (and (supports-recipients? channel_type) (seq (get recipients-by-type true)))
       (update-recipients! id (get recipients-by-type true)))
     ;; return the id of our newly created channel
diff --git a/src/metabase/models/task_history.clj b/src/metabase/models/task_history.clj
index ae5565f52b1..1e4299e82a1 100644
--- a/src/metabase/models/task_history.clj
+++ b/src/metabase/models/task_history.clj
@@ -83,13 +83,18 @@
 ;;; |                                            with-task-history macro                                             |
 ;;; +----------------------------------------------------------------------------------------------------------------+
 
+(def ^:private TaskHistoryCallBackInfo
+  [:map {:closed true}
+   [:status                        (into [:enum] task-history-status)]
+   [:task_details {:optional true} [:maybe :map]]])
+
 (def ^:private TaskHistoryInfo
   "Schema for `info` passed to the `with-task-history` macro."
   [:map {:closed true}
    [:task                             ms/NonBlankString] ; task name, i.e. `send-pulses`. Conventionally lisp-cased
    [:db_id           {:optional true} [:maybe :int]]     ; DB involved, for sync operations or other tasks where this is applicable.
-   ;; a function that takes the result of the task and returns a map of additional info to update task history when the task succeeds
-   [:on-success-info {:optional true} [:maybe [:=> [:cat :any] :map]]]
+   [:on-success-info {:optional true} [:maybe [:=> [:cat TaskHistoryCallBackInfo :any] :map]]]
+   [:on-fail-info    {:optional true} [:maybe [:=> [:cat TaskHistoryCallBackInfo :any] :map]]]
    [:task_details    {:optional true} [:maybe :map]]])   ; additional map of details to include in the recorded row
 
 (def ^:private ns->ms #(int (/ % 1e6)))
@@ -104,8 +109,9 @@
 (mu/defn do-with-task-history
   "Impl for `with-task-history` macro; see documentation below."
   [info :- TaskHistoryInfo f]
-  (let [on-success-info (:on-success-info info)
-        info            (dissoc info :on-success-info)
+  (let [on-success-info (or (:on-success-info info) (fn [& args] (first args)))
+        on-fail-info    (or (:on-fail-info info) (fn [& args] (first args)))
+        info            (dissoc info :on-success-info :on-fail-info)
         start-time-ns   (System/nanoTime)
         th-id           (t2/insert-returning-pk! :model/TaskHistory
                                                  (assoc info
@@ -113,28 +119,37 @@
                                                         :started_at (t/instant)))]
     (try
       (u/prog1 (f)
-        (update-task-history! th-id start-time-ns (cond-> {:status :success}
-                                                    (some? on-success-info)
-                                                    (merge (on-success-info <>)))))
+        (update-task-history! th-id start-time-ns (on-success-info {:status       :success
+                                                                    :task_details (:task_details info)}
+                                                                   <>)))
       (catch Throwable e
-        (update-task-history! th-id start-time-ns {:task_details {:status        :failed
-                                                                  :exception     (class e)
-                                                                  :message       (.getMessage e)
-                                                                  :stacktrace    (u/filtered-stacktrace e)
-                                                                  :ex-data       (ex-data e)
-                                                                  :original-info (:task_details info)}
-                                                   :status       :failed})
+        (update-task-history! th-id start-time-ns
+                              (on-fail-info {:task_details {:status        :failed
+                                                            :exception     (class e)
+                                                            :message       (.getMessage e)
+                                                            :stacktrace    (u/filtered-stacktrace e)
+                                                            :ex-data       (ex-data e)
+                                                            :original-info (:task_details info)}
+                                             :status       :failed}
+                                            e))
         (throw e)))))
 
 (defmacro with-task-history
   "Record a TaskHistory before executing the body, updating TaskHistory accordingly when the body completes.
-  `info` should contain at least a name for the task (conventionally
-  lisp-cased) as `:task`; see the `TaskHistoryInfo` schema in this namespace for other optional keys.
+  `info` should contain at least a name for the task (conventionally lisp-cased) as `:task`;
+  see the `TaskHistoryInfo` schema in this namespace for other optional keys.
 
     (with-task-history {:task \"send-pulses\"
                         :db_id 1
-                        :on-success-info (fn [thunk-result] {:status :failed})}
-      ...)"
+                        :on-success-info (fn [info thunk-result] (assoc-in info [:task-details :thunk-result] thunk-result)})
+                        :on-fail-info (fn [info e] (assoc-in info [:task-details :exception-class] (class e)))}
+      ...)
+
+  Optionally takes:
+    - on-success-info: a function that takes the updated task history and the result of the task,
+      returns a map of task history info to update when the task succeeds.
+    - on-fail-info: a function that takes the updated task history and the exception thrown by the task,
+      returns a map of task history info to update when the task fails."
   {:style/indent 1}
   [info & body]
   `(do-with-task-history ~info (fn [] ~@body)))
diff --git a/src/metabase/pulse.clj b/src/metabase/pulse.clj
index 71d039e1909..d7293dd4b99 100644
--- a/src/metabase/pulse.clj
+++ b/src/metabase/pulse.clj
@@ -10,6 +10,7 @@
    [metabase.models.interface :as mi]
    [metabase.models.pulse :as pulse :refer [Pulse]]
    [metabase.models.serialization :as serdes]
+   [metabase.models.task-history :as task-history]
    [metabase.pulse.parameters :as pulse-params]
    [metabase.pulse.util :as pu]
    [metabase.query-processor.timezone :as qp.timezone]
@@ -241,43 +242,71 @@
     true))
 
 (defn- get-notification-info
-  [pulse parts channel]
+  [pulse parts pulse-channel]
   (let [alert? (nil? (:dashboard_id pulse))]
-    (merge {:payload-type (if alert?
-                            :notification/alert
-                            :notification/dashboard-subscription)
-            :payload      (if alert? (first parts) parts)
-            :pulse        pulse
-            :channel      channel}
+    (merge {:payload-type  (if alert?
+                             :notification/alert
+                             :notification/dashboard-subscription)
+            :payload       (if alert? (first parts) parts)
+            :pulse         pulse
+            :pulse-channel pulse-channel}
            (if alert?
              {:card  (t2/select-one :model/Card (-> parts first :card :id))}
              {:dashboard (t2/select-one :model/Dashboard (:dashboard_id pulse))}))))
 
-(defn- channels-to-channel-recipients
-  [channel]
-  (if (= :slack (keyword (:channel_type channel)))
-    [(get-in channel [:details :channel])]
-    (for [recipient (:recipients channel)]
+(defn- channel-recipients
+  [pulse-channel]
+  (case (keyword (:channel_type pulse-channel))
+    :slack
+    [(get-in pulse-channel [:details :channel])]
+    :email
+    (for [recipient (:recipients pulse-channel)]
       (if-not (:id recipient)
         {:kind :external-email
          :email (:email recipient)}
         {:kind :user
-         :user recipient}))))
-
-(defn- channel-send!
-  [& args]
-  (try
-    (apply channel/send! args)
-    (catch Exception e
-      ;; Token errors have already been logged and we should not retry.
-      (when-not (and (= :channel/slack (first args))
-                     (contains? (:errors (ex-data e)) :slack-token))
-        (throw e)))))
+         :user recipient}))
+    :http
+    []
+    (do
+      (log/warnf "Unknown channel type %s" (:channel_type pulse-channel))
+      [])))
+
+(defn- should-retry-sending?
+  [exception channel-type]
+  (and (= :channel/slack channel-type)
+       (contains? (:errors (ex-data exception)) :slack-token)))
 
 (defn- send-retrying!
-  [& args]
+  [pulse-id channel message]
   (try
-    (apply (retry/decorate channel-send!) args)
+    (let [;; once we upgraded to retry 2.x, we can use (.. retry getMetrics getNumberOfTotalCalls) instead of tracking
+          ;; this manually
+          retry-config (retry/retry-configuration)
+          retry-errors (volatile! [])
+          retry-report (fn []
+                         {:attempted-retries (count @retry-errors)
+                          :retry-errors       @retry-errors})
+          send!        (fn []
+                         (try
+                           (channel/send! channel message)
+                           (catch Exception e
+                             (vswap! retry-errors conj e)
+                             ;; Token errors have already been logged and we should not retry.
+                             (when-not (should-retry-sending? e (:type channel))
+                               (throw e)))))]
+      (task-history/with-task-history {:task            "channel-send"
+                                       :on-success-info (fn [update-map _result]
+                                                          (cond-> update-map
+                                                            (seq @retry-errors)
+                                                            (update :task_details merge (retry-report))))
+                                       :on-fail-info    (fn [update-map _result]
+                                                          (update update-map :task_details #(merge % (retry-report))))
+                                       :task_details    {:retry-config retry-config
+                                                         :channel-type (:type channel)
+                                                         :channel-id   (:id channel)
+                                                         :pulse-id     pulse-id}}
+        ((retry/decorate send! (retry/random-exponential-backoff-retry (str (random-uuid)) retry-config)))))
     (catch Throwable e
       (log/error e "Error sending notification!"))))
 
@@ -294,6 +323,15 @@
           :when part]
       part)))
 
+(defn- pc->channel
+  "Given a pulse channel, return the channel object.
+
+  Only supports HTTP channels for now, returns a map with type key for slack and email"
+  [{channel-type :channel_type :as pulse-channel}]
+  (if (= :http (keyword channel-type))
+    (t2/select-one :model/Channel :id (:channel_id pulse-channel))
+    {:type (keyword "channel" (name channel-type))}))
+
 (defn- send-pulse!*
   [{:keys [channels channel-ids] pulse-id :id :as pulse} dashboard]
   (let [parts                  (execute-pulse pulse dashboard)
@@ -309,18 +347,25 @@
                                            :user-id (:creator_id pulse)
                                            :object  {:recipients (map :recipients (:channels pulse))
                                                      :filters    (:parameters pulse)}})
-        (u/prog1 (doseq [channel channels]
+        (u/prog1 (doseq [pulse-channel channels]
                    (try
-                     (let [channel-type (if (= :email (keyword (:channel_type channel)))
-                                          :channel/email
-                                          :channel/slack)
-                           messages     (channel/render-notification channel-type
-                                                                     (get-notification-info pulse parts channel)
-                                                                     (channels-to-channel-recipients channel))]
+                     (let [channel  (pc->channel pulse-channel)
+                           messages (channel/render-notification (:type channel)
+                                                                 (get-notification-info pulse parts pulse-channel)
+                                                                 (channel-recipients pulse-channel))]
+                       (log/debugf "Rendered %d messages for %s %d to channel %s"
+                                   (count messages)
+                                   (alert-or-pulse pulse)
+                                   (:id pulse)
+                                   (:type channel))
                        (doseq [message messages]
-                         (send-retrying! channel-type message)))
+                         (log/debugf "Sending %s %d to channel %s"
+                                     (alert-or-pulse pulse)
+                                     (:id pulse)
+                                     (:channel_type pulse-channel))
+                         (send-retrying! pulse-id channel message)))
                      (catch Exception e
-                       (log/errorf e "Error sending %s %d to channel %s" (alert-or-pulse pulse) (:id pulse) (:channel_type channel)))))
+                       (log/errorf e "Error sending %s %d to channel %s" (alert-or-pulse pulse) (:id pulse) (:channel_type pulse-channel)))))
           (when (:alert_first_only pulse)
             (t2/delete! Pulse :id pulse-id))))
       (log/infof "Skipping sending %s %d" (alert-or-pulse pulse) (:id pulse)))))
diff --git a/src/metabase/pulse/render.clj b/src/metabase/pulse/render.clj
index 13ea2e643e1..a23b3e04811 100644
--- a/src/metabase/pulse/render.clj
+++ b/src/metabase/pulse/render.clj
@@ -208,7 +208,15 @@
   ^bytes [timezone-id :- [:maybe :string] pulse-card result width]
   (png/render-html-to-png (render-pulse-card :inline timezone-id pulse-card nil result) width))
 
+(mu/defn render-pulse-card-to-base64 :- string?
+  "Render a `pulse-card` as a PNG and return it as a base64 encoded string."
+  ^String [timezone-id card dashcard result width]
+  (-> (render-pulse-card :inline timezone-id card dashcard result)
+      (png/render-html-to-png width)
+      image-bundle/render-img-data-uri))
+
 (mu/defn png-from-render-info :- bytes?
   "Create a PNG file (as a byte array) from rendering info."
   ^bytes [rendered-info :- formatter/RenderedPulseCard width]
+  ;; TODO huh? why do we need this indirection?
   (png/render-html-to-png rendered-info width))
diff --git a/src/metabase/sync/util.clj b/src/metabase/sync/util.clj
index eca687eb5ed..3e93cd05f29 100644
--- a/src/metabase/sync/util.clj
+++ b/src/metabase/sync/util.clj
@@ -488,10 +488,10 @@
                         (task-history/with-task-history
                          {:task            step-name
                           :db_id           (u/the-id database)
-                          :on-success-info (fn [result]
+                          :on-success-info (fn [update-map result]
                                              (if (instance? Throwable result)
                                                (throw result)
-                                               {:task_details (dissoc result :start-time :end-time :log-summary-fn)}))}
+                                               (assoc update-map :task_details (dissoc result :start-time :end-time :log-summary-fn))))}
                           (apply sync-fn database args))
                         (catch Throwable e
                           (if *log-exceptions-and-continue?*
diff --git a/src/metabase/task/persist_refresh.clj b/src/metabase/task/persist_refresh.clj
index 7eb27605444..3d126719037 100644
--- a/src/metabase/task/persist_refresh.clj
+++ b/src/metabase/task/persist_refresh.clj
@@ -114,7 +114,7 @@
   [task-type db-id thunk]
   (task-history/with-task-history {:task            task-type
                                    :db_id           db-id
-                                   :on-success-info (fn [task-details]
+                                   :on-success-info (fn [_update-map task-details]
                                                       (let [error (error-details task-details)]
                                                         (when (and error (= "persist-refresh" task-type))
                                                           (send-persist-refresh-email-if-error! db-id task-details))
diff --git a/src/metabase/util.cljc b/src/metabase/util.cljc
index aa0d044a2e5..0346357eaf7 100644
--- a/src/metabase/util.cljc
+++ b/src/metabase/util.cljc
@@ -185,6 +185,11 @@
       (str (upper-case-en (subs s 0 1))
            (lower-case-en (subs s 1))))))
 
+(defn truncate
+  "Truncate a string to `n` characters."
+  [s n]
+  (subs s 0 (min (count s) n)))
+
 (defn regex->str
   "Returns the contents of a regex as a string.
 
@@ -834,17 +839,16 @@
   return a map of 3 keys: `:to-create`, `:to-update`, `:to-delete`.
 
   Where:
-  - `:to-create` is a list of maps that ids in `new-rows`
+  - `:to-create` is a list of maps that has ids only in `new-rows`
   - `:to-delete` is a list of maps that has ids only in `current-rows`
   - `:to-skip`   is a list of identical maps that has ids in both lists
   - `:to-update` is a list of different maps that has ids in both lists
 
   Optional arguments:
   - `id-fn` - function to get row-matching identifiers
-  - `to-compare` - function to get rows into a comparable state
-  "
+  - `to-compare` - function to get rows into a comparable state"
   [current-rows new-rows & {:keys [id-fn to-compare]
-                            :or   {id-fn   :id
+                            :or   {id-fn      :id
                                    to-compare identity}}]
   (let [[delete-ids
          create-ids
diff --git a/src/metabase/util/malli/schema.clj b/src/metabase/util/malli/schema.clj
index 41ad733371c..dc424376dee 100644
--- a/src/metabase/util/malli/schema.clj
+++ b/src/metabase/util/malli/schema.clj
@@ -198,6 +198,12 @@
     [:fn u/email?]]
    (deferred-tru "value must be a valid email address.")))
 
+(def Url
+  "Schema for a valid URL string."
+  (mu/with-api-error-message
+   [:fn u/url?]
+   (deferred-tru "value must be a valid URL.")))
+
 (def ValidPassword
   "Schema for a valid password of sufficient complexity which is not found on a common password list."
   (mu/with-api-error-message
diff --git a/src/metabase/util/retry.clj b/src/metabase/util/retry.clj
index 075194ebf5c..f94fa75984f 100644
--- a/src/metabase/util/retry.clj
+++ b/src/metabase/util/retry.clj
@@ -34,7 +34,9 @@
   :type :integer
   :default 30000)
 
-(defn- retry-configuration []
+(defn retry-configuration
+  "Returns a map with the default retry configuration."
+  []
   {:max-attempts (retry-max-attempts)
    :initial-interval-millis (retry-initial-interval)
    :multiplier (retry-multiplier)
@@ -74,5 +76,6 @@
    (decorate f (random-exponential-backoff-retry (str (random-uuid)) (retry-configuration))))
   ([f ^Retry retry]
    (fn [& args]
-     (let [callable (reify Callable (call [_] (apply f args)))]
+     (let [callable (reify Callable (call [_]
+                                      (apply f args)))]
        (.call (Retry/decorateCallable retry callable))))))
diff --git a/test/metabase/api/alert_test.clj b/test/metabase/api/alert_test.clj
index 097719fd561..0bf7bb0d241 100644
--- a/test/metabase/api/alert_test.clj
+++ b/test/metabase/api/alert_test.clj
@@ -3,6 +3,7 @@
   (:require
    [clojure.test :refer :all]
    [medley.core :as m]
+   [metabase.channel.http-test :as channel.http-test]
    [metabase.email-test :as et]
    [metabase.http-client :as client]
    [metabase.models
@@ -286,7 +287,8 @@
                                  :updated_at    true
                                  :pulse_id      true
                                  :id            true
-                                 :created_at    true})]
+                                 :created_at    true
+                                 :channel_id    false})]
    :skip_if_empty       true
    :collection_id       false
    :collection_position nil
@@ -411,6 +413,34 @@
                                       #"meets its goal"
                                       #"My question")))))))
 
+(defn- default-http-channel
+  [id]
+  {:enabled       true
+   :channel_type  "http"
+   :channel_id    id
+   :details       {}
+   :schedule_type "daily"
+   :schedule_hour 12
+   :schedule_day  nil})
+
+(deftest create-alert-with-http-channel-test
+  (testing "Creating an alert with a HTTP channel"
+    (mt/with-model-cleanup [:model/Pulse]
+      (channel.http-test/with-server [url [channel.http-test/get-200]]
+        (mt/with-temp [:model/Channel channel {:type    :channel/http
+                                               :details {:auth-method "none"
+                                                         :url         (str url (:path channel.http-test/get-200))}}
+                       :model/Card    {card-id :id} {}]
+          (let [pulse (mt/user-http-request :crowberto :post 200 "alert"
+                                            {:alert_condition  "rows"
+                                             :alert_first_only false
+                                             :card             {:id card-id, :include_csv false, :include_xls false, :dashboard_card_id nil}
+                                             :channels         [(default-http-channel (:id channel))]})]
+            (is (=? {:pulse_id     (:id pulse)
+                     :channel_type :http
+                     :channel_id   (:id channel)}
+                    (t2/select-one :model/PulseChannel :pulse_id (:id pulse))))))))))
+
 ;;; +----------------------------------------------------------------------------------------------------------------+
 ;;; |                                               PUT /api/alert/:id                                               |
 ;;; +----------------------------------------------------------------------------------------------------------------+
@@ -627,6 +657,26 @@
             (mt/user-http-request :rasta :put 403 (alert-url alert)
                                   (dissoc (default-alert-req card pc {} []) :channels))))))))
 
+(deftest update-alert-enable-http-channel-test
+  (mt/with-temp [:model/Pulse     alert (basic-alert)
+                 :model/Card      card  {}
+                 :model/PulseCard _     (pulse-card alert card)
+                 :model/Channel   {channel-id :id} {:type    :channel/http
+                                                    :details {:auth-method "none"
+                                                              :url         "https://metabasetest.com"}}]
+    (testing "PUT /api/channel/:id can enable a HTTP channel for an alert"
+      (is (=? {:channels [{:channel_type "http"
+                           :channel_id   channel-id
+                           :enabled true}]}
+              (mt/user-http-request :crowberto :put 200 (alert-url alert)
+                                    (default-alert-req card (u/the-id alert) {:channels [(default-http-channel channel-id)]} nil))))
+
+      (testing "make sure it's in the database"
+        (is (=? {:pulse_id     (:id alert)
+                 :channel_type :http
+                 :channel_id   channel-id}
+                (t2/select-one :model/PulseChannel :pulse_id (:id alert))))))))
+
 (deftest alert-event-test
   (mt/with-premium-features #{:audit-app}
     (mt/with-non-admin-groups-no-root-collection-perms
diff --git a/test/metabase/api/channel_test.clj b/test/metabase/api/channel_test.clj
new file mode 100644
index 00000000000..600d9907a2d
--- /dev/null
+++ b/test/metabase/api/channel_test.clj
@@ -0,0 +1,193 @@
+(ns metabase.api.channel-test
+  (:require
+   [clojure.test :refer :all]
+   [metabase.channel.core :as channel]
+   [metabase.channel.http-test :as channel.http-test]
+   [metabase.public-settings.premium-features :as premium-features]
+   [metabase.test :as mt]
+   [toucan2.core :as t2]))
+
+(set! *warn-on-reflection* true)
+
+(defmethod channel/can-connect? :channel/metabase-test
+  [_channel-type {:keys [return-type return-value] :as _details}]
+  (case return-type
+    "throw"
+    (throw (ex-info "Test error" return-value))
+
+    "return-value"
+    return-value))
+
+(def default-test-channel
+  {:name        "Test channel"
+   :description "Test channel description"
+   :type        "channel/metabase-test"
+   :details     {:return-type  "return-value"
+                 :return-value true}
+   :active      true})
+
+(deftest CRU-channel-test
+  (mt/with-model-cleanup [:model/Channel]
+    (let [channel (testing "can create a channel"
+                    (mt/user-http-request :crowberto :post 200 "channel" default-test-channel))]
+      (testing "can get the channel"
+        (is (=? {:name        "Test channel"
+                 :description "Test channel description"
+                 :type        "channel/metabase-test"
+                 :details     {:return-type  "return-value"
+                               :return-value true}
+                 :active      true}
+                (mt/user-http-request :crowberto :get 200 (str "channel/" (:id channel))))))
+
+      (testing "can update channel name"
+        (mt/user-http-request :crowberto :put 200 (str "channel/" (:id channel))
+                              {:name "New Name"})
+        (is (= "New Name" (t2/select-one-fn :name :model/Channel (:id channel)))))
+
+      (testing "can't update channel details if fail to connect"
+        (mt/user-http-request :crowberto :put 400 (str "channel/" (:id channel))
+                              {:details {:return-type  "return-value"
+                                         :return-value false}})
+        (is (= {:return-type "return-value"
+                :return-value true}
+               (t2/select-one-fn :details :model/Channel (:id channel)))))
+
+      (testing "can update channel details if connection is successful"
+        (mt/user-http-request :crowberto :put 200 (str "channel/" (:id channel))
+                              {:details {:return-type  "return-value"
+                                         :return-value true
+                                         :new-data     true}})
+        (is (= {:return-type "return-value"
+                :return-value true
+                :new-data     true}
+               (t2/select-one-fn :details :model/Channel (:id channel)))))
+
+      (testing "can update channel description"
+        (mt/user-http-request :crowberto :put 200 (str "channel/" (:id channel))
+                              {:description "New description"})
+        (is (= "New description" (t2/select-one-fn :description :model/Channel (:id channel)))))
+
+      (testing "can disable a channel"
+        (mt/user-http-request :crowberto :put 200 (str "channel/" (:id channel))
+                              {:active false})
+        (is (= false (t2/select-one-fn :active :model/Channel (:id channel))))))))
+
+(deftest create-channel-with-existing-name-error-test
+  (let [channel-details default-test-channel]
+    (mt/with-temp [:model/Channel _chn channel-details]
+      (is (= {:errors {:name "Channel with that name already exists"}}
+             (mt/user-http-request :crowberto :post 409 "channel" default-test-channel))))))
+
+(def ns-keyword->str #(str (.-sym %)))
+
+(deftest list-channels-test
+  (mt/with-temp [:model/Channel chn-1 default-test-channel
+                 :model/Channel chn-2 (assoc default-test-channel
+                                             :active false
+                                             :name "Channel 2")]
+    (testing "return active channels only"
+      (is (= [(update chn-1 :type ns-keyword->str)]
+             (mt/user-http-request :crowberto :get 200 "channel"))))
+
+    (testing "return all if include_inactive is true"
+      (is (= (map #(update % :type ns-keyword->str) [chn-1 (assoc chn-2 :name "Channel 2")])
+             (mt/user-http-request :crowberto :get 200 "channel" {:include_inactive true}))))))
+
+(deftest create-channel-error-handling-test
+  (testing "returns text error message if the channel return falsy value"
+    (is (= "Unable to connect channel"
+           (mt/user-http-request :crowberto :post 400 "channel"
+                                 (assoc default-test-channel :details {:return-type  "return-value"
+                                                                       :return-value false})))))
+  (testing "returns field-specific error message if the channel returns one"
+    (is (= {:errors {:email "Invalid email"}}
+           (mt/user-http-request :crowberto :post 400 "channel"
+                                 (assoc default-test-channel :details {:return-type  "return-value"
+                                                                       :return-value {:errors {:email "Invalid email"}}})))))
+
+  (testing "returns field-specific error message if the channel throws one"
+    (is (= {:errors {:email "Invalid email"}}
+           (mt/user-http-request :crowberto :post 400 "channel"
+                                 (assoc default-test-channel :details {:return-type  "throw"
+                                                                       :return-value {:errors {:email "Invalid email"}}})))))
+
+  (testing "error if channel details include undefined key"
+    (channel.http-test/with-server [url [channel.http-test/get-200]]
+      (is (= {:errors {:xyz ["disallowed key"]}}
+             (mt/user-http-request :crowberto :post 400 "channel"
+                                   (assoc default-test-channel
+                                          :type        "channel/http"
+                                          :details     {:url         (str url (:path channel.http-test/get-200))
+                                                        :method      "get"
+                                                        :auth-method "none"
+                                                        :xyz         "alo"})))))))
+
+(deftest ensure-channel-is-namespaced-test
+  (testing "POST /api/channel return 400 if channel type is not namespaced"
+    (is (=? {:errors {:type "Must be a namespaced channel. E.g: channel/http"}}
+            (mt/user-http-request :crowberto :post 400 "channel"
+                                  (assoc default-test-channel :type "metabase-test"))))
+
+    (is (=? {:errors {:type "Must be a namespaced channel. E.g: channel/http"}}
+            (mt/user-http-request :crowberto :post 400 "channel"
+                                  (assoc default-test-channel :type "metabase/metabase-test")))))
+  (testing "PUT /api/channel return 400 if channel type is not namespaced"
+    (mt/with-temp [:model/Channel chn-1 default-test-channel]
+      (is (=? {:errors {:type "nullable Must be a namespaced channel. E.g: channel/http"}}
+              (mt/user-http-request :crowberto :put 400 (str "channel/" (:id chn-1))
+                                    (assoc chn-1 :type "metabase-test"))))
+
+      (is (=? {:errors {:type "nullable Must be a namespaced channel. E.g: channel/http"}}
+              (mt/user-http-request :crowberto :put 400 (str "channel/" (:id chn-1))
+                                    (assoc chn-1 :type "metabase/metabase-test")))))))
+
+(deftest test-channel-connection-test
+  (testing "return 200 if channel connects successfully"
+    (is (= {:ok true}
+           (mt/user-http-request :crowberto :post 200 "channel/test"
+                                 (assoc default-test-channel :details {:return-type  "return-value"
+                                                                       :return-value true})))))
+
+  (testing "returns text error message if the channel return falsy value"
+    (is (= "Unable to connect channel"
+           (mt/user-http-request :crowberto :post 400 "channel/test"
+                                 (assoc default-test-channel :details {:return-type  "return-value"
+                                                                       :return-value false})))))
+  (testing "returns field-specific error message if the channel returns one"
+    (is (= {:errors {:email "Invalid email"}}
+           (mt/user-http-request :crowberto :post 400 "channel/test"
+                                 (assoc default-test-channel :details {:return-type  "return-value"
+                                                                       :return-value {:errors {:email "Invalid email"}}})))))
+
+  (testing "returns field-specific error message if the channel throws one"
+    (is (= {:errors {:email "Invalid email"}}
+           (mt/user-http-request :crowberto :post 400 "channel/test"
+                                 (assoc default-test-channel :details {:return-type  "throw"
+                                                                       :return-value {:errors {:email "Invalid email"}}}))))))
+
+(deftest channel-audit-log-test
+  (testing "audit log for channel apis"
+    (mt/with-premium-features #{:audit-app}
+      (mt/with-model-cleanup [:model/Channel]
+        (with-redefs [premium-features/enable-cache-granular-controls? (constantly true)]
+          (let [id (:id (mt/user-http-request :crowberto :post 200 "channel" default-test-channel))]
+            (testing "POST /api/channel"
+              (is (= {:details  {:description "Test channel description"
+                                 :id          id
+                                 :name        "Test channel"
+                                 :type        "channel/metabase-test"
+                                 :active      true}
+                      :model    "Channel"
+                      :model_id id
+                      :topic    :channel-create
+                      :user_id  (mt/user->id :crowberto)}
+                     (mt/latest-audit-log-entry :channel-create))))
+
+            (testing "PUT /api/channel/:id"
+              (mt/user-http-request :crowberto :put 200 (str "channel/" id) (assoc default-test-channel :name "Updated Name"))
+              (is (= {:details  {:new {:name "Updated Name"} :previous {:name "Test channel"}}
+                      :model    "Channel"
+                      :model_id id
+                      :topic    :channel-update
+                      :user_id  (mt/user->id :crowberto)}
+                     (mt/latest-audit-log-entry :channel-update))))))))))
diff --git a/test/metabase/api/pulse_test.clj b/test/metabase/api/pulse_test.clj
index df7db4f3569..9e294d287c0 100644
--- a/test/metabase/api/pulse_test.clj
+++ b/test/metabase/api/pulse_test.clj
@@ -1,10 +1,13 @@
 (ns metabase.api.pulse-test
   "Tests for /api/pulse endpoints."
   (:require
+   [clojure.string :as str]
    [clojure.test :refer :all]
    [java-time.api :as t]
    [metabase.api.card-test :as api.card-test]
+   [metabase.api.channel-test :as api.channel-test]
    [metabase.api.pulse :as api.pulse]
+   [metabase.channel.http-test :as channel.http-test]
    [metabase.http-client :as client]
    [metabase.integrations.slack :as slack]
    [metabase.models
@@ -594,6 +597,86 @@
         (is (t2/exists? PulseChannel :id (u/the-id pc)))
         (is (t2/exists? PulseChannelRecipient :id (u/the-id pcr)))))))
 
+(def pulse-channel-email-default
+  {:enabled        true
+   :channel_type   "email"
+   :channel_id     nil
+   :schedule_type  "hourly"})
+
+(deftest update-channels-no-op-test
+  (testing "PUT /api/pulse/:id"
+    (testing "If we PUT a Pulse with the same Channels, it should be a no-op"
+      (mt/with-temp
+        [:model/Pulse        {pulse-id :id} {}
+         :model/PulseChannel pc             (assoc pulse-channel-email-default :pulse_id pulse-id)]
+        (is (=? [(assoc pulse-channel-email-default :id (:id pc))]
+                (:channels (mt/user-http-request :rasta :put 200 (str "pulse/" pulse-id)
+                                                 {:channels [pc]}))))))))
+
+(deftest update-channels-change-existing-channel-test
+  (testing "PUT /api/pulse/:id"
+    (testing "update the schedule of existing pulse channel"
+      (mt/with-temp
+        [:model/Pulse        {pulse-id :id} {}
+         :model/PulseChannel pc             (assoc pulse-channel-email-default :pulse_id pulse-id)]
+        (let [new-channel (assoc pulse-channel-email-default :id (:id pc) :schedule_type "daily" :schedule_hour 7)]
+          (is (=? [new-channel]
+                  (:channels (mt/user-http-request :rasta :put 200 (str "pulse/" pulse-id)
+                                                   {:channels [new-channel]})))))))))
+
+(def pulse-channel-slack-test
+  {:enabled        true
+   :channel_type   "slack"
+   :channel_id     nil
+   :schedule_type  "hourly"
+   :details        {:channels "#general"}})
+
+(deftest update-channels-add-new-channel-test
+  (testing "PUT /api/pulse/:id"
+    (testing "add a new pulse channel"
+      (mt/with-temp
+        [:model/Pulse        {pulse-id :id} {}
+         :model/PulseChannel pc             (assoc pulse-channel-email-default :pulse_id pulse-id)]
+        (is (=? [(assoc pulse-channel-email-default :id (:id pc))
+                 pulse-channel-slack-test]
+                (->> (mt/user-http-request :rasta :put 200 (str "pulse/" pulse-id)
+                                           {:channels [pulse-channel-slack-test pc]})
+                     :channels
+                     (sort-by :channel_type))))))))
+
+(deftest update-channels-add-multiple-channels-of-the-same-type-test
+  (testing "PUT /api/pulse/:id"
+    (testing "add multiple pulse channels of the same type and disable an existing channel"
+      (mt/with-temp
+        [:model/Channel      {chn-1 :id}    api.channel-test/default-test-channel
+         :model/Channel      {chn-2 :id}    (assoc api.channel-test/default-test-channel :name "Test channel 2")
+         :model/Pulse        {pulse-id :id} {}
+         :model/PulseChannel pc-email       (assoc pulse-channel-email-default :pulse_id pulse-id)
+         :model/PulseChannel pc-slack       (assoc pulse-channel-slack-test :pulse_id pulse-id)]
+        (is (=? [(assoc pulse-channel-email-default :enabled false)
+                 {:channel_type "http"
+                  :channel_id   chn-1
+                  :enabled      true
+                  :schedule_type "hourly"}
+                 {:channel_type "http"
+                  :channel_id   chn-2
+                  :enabled      true
+                  :schedule_type "hourly"}
+                 pulse-channel-slack-test]
+                (->> (mt/user-http-request :rasta :put 200 (str "pulse/" pulse-id)
+                                           {:channels [(assoc pc-email :enabled false)
+                                                       pc-slack
+                                                       {:channel_type "http"
+                                                        :channel_id   chn-1
+                                                        :enabled      true
+                                                        :schedule_type "hourly"}
+                                                       {:channel_type "http"
+                                                        :channel_id   chn-2
+                                                        :enabled      true
+                                                        :schedule_type "hourly"}]})
+                     :channels
+                     (sort-by (juxt :channel_type :channel_id)))))))))
+
 ;;; +----------------------------------------------------------------------------------------------------------------+
 ;;; |                                   UPDATING PULSE COLLECTION POSITIONS                                          |
 ;;; +----------------------------------------------------------------------------------------------------------------+
@@ -912,6 +995,47 @@
                         :subject "Daily Sad Toucans"}
                        (mt/summarize-multipart-single-email (-> channel-messages :channel/email first) #"Daily Sad Toucans")))))))))))
 
+(deftest send-test-alert-with-http-channel-test
+  ;; see [[metabase-enterprise.advanced-config.api.pulse-test/test-pulse-endpoint-should-respect-email-domain-allow-list-test]]
+  ;; for additional EE-specific tests
+  (testing "POST /api/pulse/test send test alert to a http channel"
+    (let [requests (atom [])
+          endpoint (channel.http-test/make-route
+                    :post "/test"
+                    (fn [req]
+                      (swap! requests conj req)))]
+      (channel.http-test/with-server [url [endpoint]]
+        (mt/with-temp
+          [:model/Card    card    {:dataset_query (mt/mbql-query orders {:aggregation [[:count]]})}
+           :model/Channel channel {:type    :channel/http
+                                   :details {:url         (str url "/test")
+                                             :auth-method :none}}]
+          (mt/user-http-request :rasta :post 200 "pulse/test"
+                                {:name            (mt/random-name)
+                                 :cards           [{:id                (:id card)
+                                                    :include_csv       false
+                                                    :include_xls       false
+                                                    :dashboard_card_id nil}]
+                                 :channels        [{:enabled       true
+                                                    :channel_type  "http"
+                                                    :channel_id    (:id channel)
+                                                    :schedule_type "daily"
+                                                    :schedule_hour 12
+                                                    :schedule_day  nil
+                                                    :recipients    []}]
+                                 :alert_condition "rows"})
+          (is (=? {:body {:alert_creator_id   (mt/user->id :rasta)
+                          :alert_creator_name "Rasta Toucan"
+                          :alert_id           nil
+                          :data               {:question_id   (:id card)
+                                               :question_name (mt/malli=? string?)
+                                               :question_url  (mt/malli=? string?)
+                                               :raw_data      {:cols ["count"], :rows [[18760]]},
+                                               :type          "question"
+                                               :visualization (mt/malli=? [:fn #(str/starts-with? % "data:image/png;base64,")])}
+                          :type               "alert"}}
+                  (first @requests))))))))
+
 (deftest send-test-pulse-validate-emails-test
   (testing (str "POST /api/pulse/test should call " `pulse-channel/validate-email-domains)
     (t2.with-temp/with-temp [Card card {:dataset_query (mt/mbql-query venues)}]
@@ -998,6 +1122,34 @@
                        [:data :viz-settings])))))
 
 (deftest form-input-test
+  (testing "GET /api/pulse/form_input"
+    (mt/with-temporary-setting-values
+      [slack/slack-app-token "test-token"]
+      (mt/with-temp [:model/Channel _ {:type :channel/http :details {:url "https://metabasetest.com" :auth-method "none"}}]
+        (is (= {:channels {:email {:allows_recipients true
+                                   :configured        false
+                                   :name              "Email"
+                                   :recipients        ["user" "email"]
+                                   :schedules         ["hourly" "daily" "weekly" "monthly"]
+                                   :type              "email"}
+                           :http  {:allows_recipients false
+                                   :configured        true
+                                   :name              "Webhook"
+                                   :schedules         ["hourly" "daily" "weekly" "monthly"]
+                                   :type              "http"}
+                           :slack {:allows_recipients false
+                                   :configured        true
+                                   :fields            [{:displayName "Post to"
+                                                        :name        "channel"
+                                                        :options     []
+                                                        :required    true
+                                                        :type        "select"}]
+                                   :name             "Slack"
+                                   :schedules        ["hourly" "daily" "weekly" "monthly"]
+                                   :type             "slack"}}}
+               (mt/user-http-request :rasta :get 200 "pulse/form_input")))))))
+
+(deftest form-input-slack-test
   (testing "GET /api/pulse/form_input"
     (testing "Check that Slack channels come back when configured"
       (mt/with-temporary-setting-values [slack/slack-channels-and-usernames-last-updated
diff --git a/test/metabase/channel/email_test.clj b/test/metabase/channel/email_test.clj
index 499572bc7fa..4bb3efd015a 100644
--- a/test/metabase/channel/email_test.clj
+++ b/test/metabase/channel/email_test.clj
@@ -14,9 +14,9 @@
                                            email-smtp-host    " ake_smtp_host"
                                            email-smtp-port    587
                                            bcc-enabled?       false]
-          (channel/send! :channel/email {:subject      "Test"
-                                         :recipients   ["ngoc@metabase.com"]
-                                         :message-type :html
-                                         :message      "Test message"})
+          (channel/send! {:type :channel/email} {:subject      "Test"
+                                                 :recipients   ["ngoc@metabase.com"]
+                                                 :message-type :html
+                                                 :message      "Test message"})
           (is (=? {:to ["ngoc@metabase.com"]}
                   @sent-message)))))))
diff --git a/test/metabase/channel/http_test.clj b/test/metabase/channel/http_test.clj
new file mode 100644
index 00000000000..1f84e72d5be
--- /dev/null
+++ b/test/metabase/channel/http_test.clj
@@ -0,0 +1,346 @@
+(ns metabase.channel.http-test
+  (:require
+   [cheshire.core :as json]
+   [clj-http.client :as http]
+   [clojure.string :as str]
+   [clojure.test :refer :all]
+   [compojure.core :as compojure]
+   [medley.core :as m]
+   [metabase.channel.core :as channel]
+   [metabase.pulse :as pulse]
+   [metabase.test :as mt]
+   [metabase.util.i18n :refer [deferred-tru]]
+   [ring.adapter.jetty :as jetty]
+   [ring.middleware.params :refer [wrap-params]]
+   [toucan2.core :as t2])
+  (:import
+   (org.eclipse.jetty.server Server)))
+
+(set! *warn-on-reflection* true)
+
+(defn do-with-captured-http-requests
+  [f]
+  (let [requests (atom [])]
+    (binding [http/request (fn [req]
+                             (swap! requests conj req)
+                             ::noop)]
+      (f requests))))
+
+(defmacro ^:private with-captured-http-requests
+  [[requests-binding] & body]
+  `(do-with-captured-http-requests
+    (fn [~requests-binding]
+      ~@body)))
+
+(def ^:private default-request
+  {:accept       :json
+   :content-type :json})
+
+(defn apply-middleware
+  [handler middlewares]
+  (reduce
+   (fn [handler middleware-fn]
+     (middleware-fn handler))
+   handler
+   middlewares))
+
+(defn- json-mw [handler]
+  (fn [req]
+    (-> req
+        (m/update-existing :body #(-> % slurp (json/parse-string true)))
+        handler)))
+
+(def middlewares [json-mw wrap-params])
+
+(defn do-with-server
+  [route+handlers f]
+  (let [handler        (->> route+handlers
+                            (map :route)
+                            (apply compojure/routes))
+        ^Server server (jetty/run-jetty (apply-middleware handler middlewares) {:port 0 :join? false})]
+    (try
+      (f (str "http://localhost:" (.. server getURI getPort)))
+      (finally
+        (.stop server)))))
+
+(defn make-route
+  "Create a route to be used with [[with-server]].
+
+  (make-route :get \"/test\" (fn [req] {:status 200 :body \"Hello, world!\"}))"
+  [method path handler]
+  {:path  path
+   :route (compojure/make-route method path handler)})
+
+(defmacro with-server
+  "Create a temporary server given a list of routes and handlers, and execute the body
+  with the server URL binding.
+
+  (with-server [url [(make-route :get (identity {:status 200}))] & handlers]
+  (http/get (str url \"/test_http_channel_200\"))"
+  [[url-binding handlers] & body]
+  `(do-with-server
+    ~handlers
+    (fn [~url-binding]
+      ~@body)))
+
+(def get-favicon
+  (make-route :get "/favicon.ico"
+              (fn [_]
+                {:status 200
+                 :body   "Favicon"})))
+
+(def get-200
+  (make-route :get "/test_http_channel_200"
+              (fn [_]
+                {:status 200
+                 :body   "Hello, world!"})))
+
+(def post-200
+  (make-route :post "/test_http_channel_200"
+              (fn [_]
+                {:status 200
+                 :body   "Hello, world!"})))
+
+(def get-302-redirect-200
+  (make-route :get "/test_http_channel_302_redirect_200"
+              (fn [_]
+                {:status  302
+                 :headers {"Location" (:path get-200)}})))
+
+(def get-400
+  (make-route :get "/test_http_channel_400"
+              (fn [_]
+                {:status 400
+                 :body   "Bad request"})))
+
+(def post-400
+  (make-route :post "/test_http_channel_400"
+              (fn [_]
+                {:status 400
+                 :body   "Bad request"})))
+
+(def get-302-redirect-400
+  (make-route :get "/test_http_channel_302_redirect_400"
+              (fn [_]
+                {:status  302
+                 :headers {"Location" (:path get-400)}})))
+
+(def get-500
+  (make-route :get "/test_http_channel_500"
+              (fn [_]
+                {:status 500
+                 :body   "Internal server error"})))
+
+(defn can-connect?
+  [details]
+  (channel/can-connect? :channel/http details))
+
+(defmacro exception-data
+  [& body]
+  `(try
+     ~@body
+     (catch Exception e#
+       (ex-data e#))))
+
+(deftest ^:parallel can-connect-no-auth-test
+  (with-server [url [get-favicon get-200 get-302-redirect-200 get-400 get-302-redirect-400]]
+    (let [can-connect?* (fn [route]
+                          (can-connect? {:url         (str url (:path route))
+                                         :auth-method "none"
+                                         :method      "get"}))]
+
+      (testing "connect successfully with 200"
+        (is (true? (can-connect?* get-200))))
+      (testing "connect successfully with 302 redirect to 200"
+        (is (true? (can-connect?* get-302-redirect-200))))
+      (testing "failed to connect with a 302 that redirects to 400"
+        (is (= {:request-status 400
+                :request-body   "Bad request"}
+               (exception-data (can-connect?* get-302-redirect-400)))))
+      (testing "failed to conenct to a 400"
+        (is (= {:request-status 400
+                :request-body   "Bad request"}
+               (exception-data (can-connect?* get-400)))))
+      (is (=? {:request-status 500
+               ;; not sure why it's returns a response map is nil body
+               ;; looks like a jetty bug: https://stackoverflow.com/q/46299061
+               #_:request-body   #_"Internal server error"}
+              (exception-data (can-connect?* get-500)))))))
+
+(deftest ^:parallel can-connect-header-auth-test
+  (with-server [url [(make-route :get "/user"
+                                 (fn [x]
+                                   (if (= "SECRET" (get-in x [:headers "x-api-key"]))
+                                     {:status 200
+                                      :body   "Hello, world!"}
+                                     {:status 401
+                                      :body   "Unauthorized"})))]]
+    (testing "connect successfully with header auth"
+      (is (true? (can-connect? {:url         (str url "/user")
+                                :method      "get"
+                                :auth-method "header"
+                                :auth-info   {:x-api-key "SECRET"}}))))
+
+    (testing "fail to connect with header auth"
+      (is (= {:request-status 401
+              :request-body   "Unauthorized"}
+             (exception-data (can-connect? {:url         (str url "/user")
+                                            :method      "get"
+                                            :auth-method "header"
+                                            :auth-info   {:x-api-key "WRONG"}})))))))
+
+(deftest ^:parallel can-connect-query-param-auth-test
+  (with-server [url [(make-route :get "/user"
+                                 (fn [x]
+                                   (if (= ["qnkhuat" "secretpassword"]
+                                          [(get-in x [:query-params "username"]) (get-in x [:query-params "password"])])
+                                     {:status 200
+                                      :body   "Hello, world!"}
+                                     {:status 401
+                                      :body   "Unauthorized"})))]]
+    (testing "connect successfully with query-param auth"
+      (is (true? (can-connect? {:url         (str url "/user")
+                                :method      "get"
+                                :auth-method "query-param"
+                                :auth-info   {:username "qnkhuat"
+                                              :password "secretpassword"}}))))
+    (testing "fail to connect with query-param auth"
+      (is (= {:request-status 401
+              :request-body   "Unauthorized"}
+             (exception-data (can-connect? {:url         (str url "/user")
+                                            :method      "get"
+                                            :auth-method "query-param"
+                                            :auth-info   {:username "qnkhuat"
+                                                          :password "wrongpassword"}})))))))
+
+(deftest ^:parallel can-connect-request-body-auth-test
+  (with-server [url [(make-route :post "/user"
+                                 (fn [x]
+                                   (if (= "SECRET_TOKEN" (get-in x [:body :token]))
+                                     {:status 200
+                                      :body   "Hello, world!"}
+                                     {:status 401
+                                      :body   "Unauthorized"})))]]
+    (testing "connect successfully with request-body auth"
+      (is (true? (can-connect? {:url         (str url "/user")
+                                :method      "post"
+                                :auth-method "request-body"
+                                :auth-info   {:token "SECRET_TOKEN"}}))))
+    (testing "fail to connect with request-body auth"
+      (is (= {:request-status 401
+              :request-body   "Unauthorized"}
+             (exception-data (can-connect? {:url         (str url "/user")
+                                            :method      "post"
+                                            :auth-method "request-body"
+                                            :auth-info   {:token "WRONG_TOKEN"}})))))))
+
+(deftest ^:parallel can-connect?-errors-test
+  (testing "throws an appriopriate errors if details are invalid"
+    (testing "invalid url"
+      (is (= {:errors {:url [(deferred-tru "value must be a valid URL.")]}}
+             (exception-data (can-connect? {:url         "not-an-url"
+                                            :auth-method "none"})))))
+
+    (testing "testing missing auth-method"
+      (is (= {:errors {:auth-method ["missing required key"]}}
+             (exception-data (can-connect? {:url "https://www.secret_service.xyz"})))))
+
+    (testing "include undefined key"
+      (is (=? {:errors {:xyz ["disallowed key"]}}
+              (exception-data (can-connect? {:xyz "hello world"})))))
+
+    (with-server [url [get-400]]
+      (is (= {:request-body   "Bad request"
+              :request-status 400}
+             (exception-data (can-connect? {:url         (str url (:path get-400))
+                                            :method      "get"
+                                            :auth-method "none"})))))))
+
+(deftest ^:parallel send!-test
+  (testing "basic send"
+    (with-captured-http-requests [requests]
+      (channel/send! {:type        :channel/http
+                      :details     {:url         "https://www.secret_service.xyz"
+                                    :auth-method "none"
+                                    :method      "get"}}
+                     nil)
+      (is (= (merge default-request
+                    {:method       :get
+                     :url          "https://www.secret_service.xyz"})
+             (first @requests)))))
+
+  (testing "default method is post"
+    (with-captured-http-requests [requests]
+      (channel/send! {:type    :channel/http
+                      :details {:url         "https://www.secret_service.xyz"
+                                :auth-method "none"}}
+                     nil)
+      (is (= (merge default-request
+                    {:method       :post
+                     :url          "https://www.secret_service.xyz"})
+             (first @requests)))))
+
+  (testing "preserves req headers when use auth-method=:header"
+    (with-captured-http-requests [requests]
+      (channel/send! {:type    :channel/http
+                      :details {:url         "https://www.secret_service.xyz"
+                                :auth-method "header"
+                                :auth-info   {:Authorization "Bearer 123"}
+                                :method      "get"}}
+                     {:headers     {:X-Request-Id "123"}})
+      (is (= (merge default-request
+                    {:method  :get
+                     :url          "https://www.secret_service.xyz"
+                     :headers      {:Authorization "Bearer 123"
+                                    :X-Request-Id "123"}})
+             (first @requests)))))
+
+  (testing "preserves req query-params when use auth-method=:query-param"
+    (with-captured-http-requests [requests]
+      (channel/send! {:type    :channel/http
+                      :details {:url         "https://www.secret_service.xyz"
+                                :auth-method "query-param"
+                                :auth-info   {:token "123"}
+                                :method      "get"}}
+                     {:query-params {:page 1}})
+      (is (= (merge default-request
+                    {:method       :get
+                     :url          "https://www.secret_service.xyz"
+                     :query-params {:token "123"
+                                    :page 1}})
+             (first @requests))))))
+
+(deftest ^:parallel alert-http-channel-e2e-test
+  (let [received-message (atom nil)
+        receive-route    (make-route :post "/test_http_channel"
+                                     (fn [res]
+                                       (reset! received-message res)
+                                       {:status 200}))]
+    (with-server [url [receive-route]]
+      (mt/with-temp
+        [:model/Card         {card-id :id
+                              :as card}     {:dataset_query (mt/mbql-query checkins {:aggregation [:count]})}
+         :model/Pulse        {pulse-id :id
+                              :as pulse}    {:alert_condition "rows"}
+         :model/PulseCard    _              {:pulse_id        pulse-id
+                                             :card_id         card-id
+                                             :position        0}
+         :model/Channel      {chn-id :id}  {:type    :channel/http
+                                            :details {:url         (str url (:path receive-route))
+                                                      :auth-method "none"}}
+         :model/PulseChannel _             {:pulse_id     pulse-id
+                                            :channel_type "http"
+                                            :channel_id   chn-id}]
+        (pulse/send-pulse! pulse)
+        (is (=? {:body {:type               "alert"
+                        :alert_id           pulse-id
+                        :alert_creator_id   (mt/malli=? int?)
+                        :alert_creator_name (t2/select-one-fn :common_name :model/User (:creator_id pulse))
+                        :data               {:type          "question"
+                                             :question_id   card-id
+                                             :question_name (:name card)
+                                             :question_url  (mt/malli=? [:fn #(str/ends-with? % (str card-id))])
+                                             :visualization (mt/malli=? [:fn #(str/starts-with? % "data:image/png;base64")])
+                                             :raw_data      {:cols ["count"] :rows [[1000]]}}
+                        :sent_at            (mt/malli=? :any)}}
+                @received-message))))))
diff --git a/test/metabase/db/schema_migrations_test.clj b/test/metabase/db/schema_migrations_test.clj
index 4bfab303a3c..c13eae36386 100644
--- a/test/metabase/db/schema_migrations_test.clj
+++ b/test/metabase/db/schema_migrations_test.clj
@@ -490,25 +490,27 @@
   (mt/test-driver :postgres
     (testing "FKs are not created automatically in Postgres, check that migrations add necessary indexes"
       (is (= [{:table_name  "field_usage"
-               :column_name "query_execution_id"}]
+               :column_name "query_execution_id"}
+              {:table_name  "pulse_channel"
+               :column_name "channel_id"}]
              (t2/query
               "SELECT
-                   conrelid::regclass::text AS table_name,
-                   a.attname AS column_name
-               FROM
-                   pg_constraint AS c
-                   JOIN pg_attribute AS a ON a.attnum = ANY(c.conkey) AND a.attrelid = c.conrelid
-               WHERE
-                   c.contype = 'f'
-                   AND NOT EXISTS (
-                       SELECT 1
-                       FROM pg_index AS i
-                       WHERE i.indrelid = c.conrelid
-                         AND a.attnum = ANY(i.indkey)
-                   )
-               ORDER BY
-                   table_name,
-                   column_name;"))))))
+                    conrelid::regclass::text AS table_name,
+                    a.attname AS column_name
+                FROM
+                    pg_constraint AS c
+                    JOIN pg_attribute AS a ON a.attnum = ANY(c.conkey) AND a.attrelid = c.conrelid
+                WHERE
+                    c.contype = 'f'
+                    AND NOT EXISTS (
+                        SELECT 1
+                        FROM pg_index AS i
+                        WHERE i.indrelid = c.conrelid
+                          AND a.attnum = ANY(i.indkey)
+                    )
+                ORDER BY
+                    table_name,
+                    column_name;"))))))
 
 (deftest remove-collection-color-test
   (testing "Migration v48.00-019"
diff --git a/test/metabase/events/audit_log_test.clj b/test/metabase/events/audit_log_test.clj
index 46fec88def3..b90773deecf 100644
--- a/test/metabase/events/audit_log_test.clj
+++ b/test/metabase/events/audit_log_test.clj
@@ -527,3 +527,37 @@
                 :topic    :password-reset-successful
                 :model    "User"}
                (mt/latest-audit-log-entry :password-reset-successful (mt/user->id :rasta))))))))
+
+(deftest create-channel-event-test
+  (mt/with-current-user (mt/user->id :rasta)
+    (mt/with-temp [:model/Channel channel {:name    "Test channel"
+                                           :type    "channel/metabase-test"
+                                           :details {:return-type  "return-value"
+                                                     :return-value true}}]
+      (testing :event/channel-create
+        (is (= {:object channel}
+               (events/publish-event! :event/channel-create {:object channel})))
+        (is (= {:model_id (:id channel)
+                :user_id  (mt/user->id :rasta)
+                :details  {:id          (:id channel)
+                           :name        "Test channel"
+                           :description nil
+                           :type        "channel/metabase-test"
+                           :active      true}
+                :topic    :channel-create
+                :model    "Channel"}
+               (mt/latest-audit-log-entry :channel-create (:id channel)))))
+
+      (testing :event/channel-update
+        (events/publish-event! :event/channel-update {:object          (assoc channel
+                                                                              :details {:new-detail true}
+                                                                              :name "New Name")
+                                                      :previous-object channel})
+
+        (is (= {:model_id (:id channel)
+                :user_id  (mt/user->id :rasta)
+                :details  {:previous {:name "Test channel"}
+                           :new      {:name "New Name"}}
+                :topic    :channel-update
+                :model    "Channel"}
+               (mt/latest-audit-log-entry :channel-update (:id channel))))))))
diff --git a/test/metabase/models/channel_test.clj b/test/metabase/models/channel_test.clj
new file mode 100644
index 00000000000..179b86c31c7
--- /dev/null
+++ b/test/metabase/models/channel_test.clj
@@ -0,0 +1,46 @@
+(ns metabase.models.channel-test
+  (:require
+   [clojure.test :refer :all]
+   [metabase.api.channel-test :as api.channel-test]
+   [metabase.test :as mt]
+   [metabase.util.encryption :as encryption]
+   [metabase.util.encryption-test :as encryption-test]
+   [toucan2.core :as t2]))
+
+(comment
+  ;; to register the :metabase-test channel implementation
+  api.channel-test/keepme)
+
+(deftest channel-details-is-encrypted
+  (encryption-test/with-secret-key "secret"
+    (mt/with-model-cleanup [:model/Channel]
+      (let [channel (t2/insert-returning-instance! :model/Channel {:name    "Test channel"
+                                                                   :type    "channel/metabase-test"
+                                                                   :details {:return-type  "return-value"
+                                                                             :return-value true}
+                                                                   :active  true})]
+        (is (encryption/possibly-encrypted-string? (t2/select-one-fn :details :channel (:id channel))))))))
+
+(deftest deactivate-channel-test
+  (mt/with-temp
+    [:model/Channel      {id :id}       {:name    "Test channel"
+                                         :type    "channel/metabase-test"
+                                         :details {:return-type  "return-value"
+                                                   :return-value true}
+                                         :active  true}
+     :model/Pulse        {pulse-id :id} {:name "Test pulse"}
+     :model/PulseChannel {pc-id :id}    {:pulse_id pulse-id
+                                         :channel_id id
+                                         :channel_type "metabase-test"
+                                         :enabled true}]
+    (testing "do not try to delete pulse-channel if active doesn't change"
+      (is (pos? (t2/update! :model/Channel id {:name "New name"})))
+      (is (pos? (t2/update! :model/Channel id {:active true})))
+      (is (t2/exists? :model/PulseChannel pc-id)))
+
+    (testing "deactivate channel"
+      (t2/update! :model/Channel id {:active false})
+      (testing "will delete pulse channels"
+        (is (not (t2/exists? :model/PulseChannel pc-id))))
+      (testing "will change the name"
+        (is (= (format "DEACTIVATED_%d New name" id) (t2/select-one-fn :name :model/Channel id)))))))
diff --git a/test/metabase/models/pulse_channel_test.clj b/test/metabase/models/pulse_channel_test.clj
index 23e4d47b00c..1734c33c97c 100644
--- a/test/metabase/models/pulse_channel_test.clj
+++ b/test/metabase/models/pulse_channel_test.clj
@@ -152,22 +152,28 @@
       (update :entity_id boolean)
       (m/dissoc-in [:details :emails])))
 
+(def default-pulse-channel
+  {:enabled        true
+   :entity_id      true
+   :channel_type   :email
+   :schedule_type  :daily
+   :schedule_hour  18
+   :schedule_day   nil
+   :schedule_frame nil
+   :recipients     []
+   :channel_id     nil})
+
 ;; create-pulse-channel!
 (deftest create-pulse-channel!-test
   (mt/with-premium-features #{}
     (t2.with-temp/with-temp [Pulse {:keys [id]}]
       (mt/with-model-cleanup [Pulse]
         (testing "disabled"
-          (is (= {:enabled        false
-                  :entity_id      true
-                  :channel_type   :email
-                  :schedule_type  :daily
-                  :schedule_hour  18
-                  :schedule_day   nil
-                  :schedule_frame nil
-                  :recipients     [(user-details :crowberto)
-                                   {:email "foo@bar.com"}
-                                   (user-details :rasta)]}
+          (is (= (merge default-pulse-channel
+                        {:enabled false
+                         :recipients [(user-details :crowberto)
+                                      {:email "foo@bar.com"}
+                                      (user-details :rasta)]})
                  (create-channel-then-select!
                   {:pulse_id      id
                    :enabled       false
@@ -178,16 +184,10 @@
                                    {:id (mt/user->id :rasta)}
                                    {:id (mt/user->id :crowberto)}]}))))
         (testing "email"
-          (is (= {:enabled        true
-                  :entity_id      true
-                  :channel_type   :email
-                  :schedule_type  :daily
-                  :schedule_hour  18
-                  :schedule_day   nil
-                  :schedule_frame nil
-                  :recipients     [(user-details :crowberto)
-                                   {:email "foo@bar.com"}
-                                   (user-details :rasta)]}
+          (is (= (merge default-pulse-channel
+                        {:recipients [(user-details :crowberto)
+                                      {:email "foo@bar.com"}
+                                      (user-details :rasta)]})
                  (create-channel-then-select!
                   {:pulse_id      id
                    :enabled       true
@@ -199,38 +199,25 @@
                                    {:id (mt/user->id :crowberto)}]}))))
 
         (testing "slack"
-          (is (= {:enabled        true
-                  :entity_id      true
-                  :channel_type   :slack
-                  :schedule_type  :hourly
-                  :schedule_hour  nil
-                  :schedule_day   nil
-                  :schedule_frame nil
-                  :recipients     []
-                  :details        {:something "random"}}
+          (is (= (merge default-pulse-channel
+                        {:channel_type :slack
+                         :schedule_type :hourly :schedule_hour nil
+                         :details {:channel "#general"}})
                  (create-channel-then-select!
                   {:pulse_id      id
                    :enabled       true
                    :channel_type  :slack
                    :schedule_type :hourly
-                   :details       {:something "random"}
-                   :recipients    [{:email "foo@bar.com"}
-                                   {:id (mt/user->id :rasta)}
-                                   {:id (mt/user->id :crowberto)}]}))))))))
+                   :details       {:channel "#general"}
+                   :recipients    []}))))))))
 
 (deftest update-pulse-channel!-test
   (mt/with-premium-features #{}
     (t2.with-temp/with-temp [Pulse {pulse-id :id}]
       (testing "simple starting case where we modify the schedule hour and add a recipient"
         (t2.with-temp/with-temp [PulseChannel {channel-id :id} {:pulse_id pulse-id}]
-          (is (= {:enabled        true
-                  :entity_id      true
-                  :channel_type   :email
-                  :schedule_type  :daily
-                  :schedule_hour  18
-                  :schedule_day   nil
-                  :schedule_frame nil
-                  :recipients     [{:email "foo@bar.com"}]}
+          (is (= (merge default-pulse-channel
+                        {:recipients [{:email "foo@bar.com"}]})
                  (update-channel-then-select!
                   {:id            channel-id
                    :enabled       true
@@ -241,14 +228,12 @@
 
       (testing "monthly schedules require a schedule_frame and can optionally omit they schedule_day"
         (t2.with-temp/with-temp [PulseChannel {channel-id :id} {:pulse_id pulse-id}]
-          (is (= {:enabled        true
-                  :entity_id      true
-                  :channel_type  :email
-                  :schedule_type :monthly
-                  :schedule_hour 8
-                  :schedule_day  nil
-                  :schedule_frame :mid
-                  :recipients    [{:email "foo@bar.com"} (user-details :rasta)]}
+          (is (= (merge default-pulse-channel
+                        {:recipients     [{:email "foo@bar.com"} (user-details :rasta)]
+                         :channel_type   :email
+                         :schedule_type  :monthly
+                         :schedule_frame :mid
+                         :schedule_hour  8})
                  (update-channel-then-select!
                   {:id             channel-id
                    :enabled        true
@@ -261,14 +246,12 @@
 
       (testing "weekly schedule should have a day in it, show that we can get full users"
         (t2.with-temp/with-temp [PulseChannel {channel-id :id} {:pulse_id pulse-id}]
-          (is (= {:enabled        true
-                  :entity_id      true
-                  :channel_type   :email
-                  :schedule_type  :weekly
-                  :schedule_hour  8
-                  :schedule_day   "mon"
-                  :schedule_frame nil
-                  :recipients     [{:email "foo@bar.com"} (user-details :rasta)]}
+          (is (= (merge default-pulse-channel
+                        {:recipients    [{:email "foo@bar.com"} (user-details :rasta)]
+                         :channel_type  :email
+                         :schedule_type :weekly
+                         :schedule_hour 8
+                         :schedule_day  "mon"})
                  (update-channel-then-select!
                   {:id            channel-id
                    :enabled       true
@@ -281,14 +264,12 @@
       (testing "hourly schedules don't require day/hour settings (should be nil), fully change recipients"
         (t2.with-temp/with-temp [PulseChannel {channel-id :id} {:pulse_id pulse-id, :details {:emails ["foo@bar.com"]}}]
           (pulse-channel/update-recipients! channel-id [(mt/user->id :rasta)])
-          (is (= {:enabled       true
-                  :entity_id     true
-                  :channel_type  :email
-                  :schedule_type :hourly
-                  :schedule_hour nil
-                  :schedule_day  nil
-                  :schedule_frame nil
-                  :recipients    [(user-details :crowberto)]}
+          (is (= (merge default-pulse-channel
+                        {:recipients    [(user-details :crowberto)]
+                         :channel_type  :email
+                         :schedule_type :hourly
+                         :schedule_hour nil
+                         :schedule_day  nil})
                  (update-channel-then-select!
                   {:id            channel-id
                    :enabled       true
@@ -300,15 +281,13 @@
 
       (testing "custom details for channels that need it"
         (t2.with-temp/with-temp [PulseChannel {channel-id :id} {:pulse_id pulse-id}]
-          (is (= {:enabled       true
-                  :entity_id     true
-                  :channel_type  :email
-                  :schedule_type :daily
-                  :schedule_hour 12
-                  :schedule_day  nil
-                  :schedule_frame nil
-                  :recipients    [{:email "foo@bar.com"} {:email "blah@bar.com"}]
-                  :details       {:channel "#metabaserocks"}}
+          (is (= (merge default-pulse-channel
+                        {:recipients    [{:email "foo@bar.com"} {:email "blah@bar.com"}]
+                         :details       {:channel "#metabaserocks"}
+                         :channel_type  :email
+                         :schedule_type :daily
+                         :schedule_hour 12
+                         :schedule_day  nil})
                  (update-channel-then-select!
                   {:id            channel-id
                    :enabled       true
diff --git a/test/metabase/models/pulse_test.clj b/test/metabase/models/pulse_test.clj
index 903b8a38b3f..90d8b57440c 100644
--- a/test/metabase/models/pulse_test.clj
+++ b/test/metabase/models/pulse_test.clj
@@ -123,27 +123,6 @@
           (is (= expected
                  (update-cards! cards))))))))
 
-;; update-notification-channels!
-(deftest update-notification-channels-test
-  (t2.with-temp/with-temp [Pulse {:keys [id]}]
-    (pulse/update-notification-channels! {:id id} [{:enabled       true
-                                                    :channel_type  :email
-                                                    :schedule_type :daily
-                                                    :schedule_hour 4
-                                                    :recipients    [{:email "foo@bar.com"} {:id (mt/user->id :rasta)}]}])
-    (is (= (merge pulse-channel-defaults
-                  {:channel_type  :email
-                   :schedule_type :daily
-                   :schedule_hour 4
-                   :recipients    [{:email "foo@bar.com"}
-                                   (dissoc (user-details :rasta) :is_superuser :is_qbnewb)]})
-           (-> (t2/select-one PulseChannel :pulse_id id)
-               (t2/hydrate :recipients)
-               (dissoc :id :pulse_id :created_at :updated_at)
-               (update :entity_id boolean)
-               (m/dissoc-in [:details :emails])
-               mt/derecordize)))))
-
 ;; create-pulse!
 ;; simple example with a single card
 (deftest create-pulse-test
diff --git a/test/metabase/models/task_history_test.clj b/test/metabase/models/task_history_test.clj
index cef463fd5ba..585187cb71b 100644
--- a/test/metabase/models/task_history_test.clj
+++ b/test/metabase/models/task_history_test.clj
@@ -101,3 +101,47 @@
                        :ended_at   (mt/malli=? some?)
                        :duration   (mt/malli=? nat-int?)}
                       (t2/select-one :model/TaskHistory :task task-name))))))))))
+
+(deftest with-task-history-using-callback-test
+  (mt/with-model-cleanup [:model/TaskHistory]
+    (testing "on-success-info"
+      (let [task-name (mt/random-name)]
+        (task-history/with-task-history {:task            task-name
+                                         :task_details    {:id 1}
+                                         :on-success-info (fn [info result]
+                                                            (testing "info should have task_details and updated status"
+                                                              (is (= {:task_details {:id 1}
+                                                                      :status       :success}
+                                                                     info)))
+                                                            (update info :task_details assoc :result result))}
+          42)
+        (is (= {:status       :success
+                :task_details {:id     1
+                               :result 42}}
+               (t2/select-one [:model/TaskHistory :status :task_details] :task task-name)))))
+
+    (testing "on-fail-info"
+      (let [task-name (mt/random-name)]
+        (u/ignore-exceptions
+          (task-history/with-task-history {:task         task-name
+                                           :task_details {:id 1}
+                                           :on-fail-info (fn [info e]
+                                                           (testing "info should have task_details and updated status"
+                                                             (is (=? {:status       :failed
+                                                                      :task_details {:status        :failed
+                                                                                     :message       "test"
+                                                                                     :stacktrace    (mt/malli=? :any)
+                                                                                     :ex-data       {:reason :test}
+                                                                                     :original-info {:id 1}}}
+                                                                     info)))
+                                                           (update info :task_details assoc :reason (ex-message e)))}
+            (throw (ex-info "test" {:reason :test}))))
+        (is (=? {:status       :failed
+                 :task_details {:status        "failed"
+                                :exception     "class clojure.lang.ExceptionInfo"
+                                :message       "test"
+                                :stacktrace    (mt/malli=? :any)
+                                :ex-data       {:reason "test"}
+                                :original-info {:id 1}
+                                :reason         "test"}}
+                (t2/select-one [:model/TaskHistory :status :task_details] :task task-name)))))))
diff --git a/test/metabase/pulse/test_util.clj b/test/metabase/pulse/test_util.clj
index 9af15ea923f..73c683ce9b0 100644
--- a/test/metabase/pulse/test_util.clj
+++ b/test/metabase/pulse/test_util.clj
@@ -82,8 +82,8 @@
 (defn do-with-captured-channel-send-messages!
   [thunk]
   (let [channel-messages (atom nil)]
-    (with-redefs [channel/send! (fn [channel-type message]
-                                  (swap! channel-messages update channel-type conj message))]
+    (with-redefs [channel/send! (fn [channel message]
+                                  (swap! channel-messages update (:type channel) conj message))]
       (thunk)
       @channel-messages)))
 
diff --git a/test/metabase/pulse_test.clj b/test/metabase/pulse_test.clj
index 2a1cf19a6de..eba9e29a483 100644
--- a/test/metabase/pulse_test.clj
+++ b/test/metabase/pulse_test.clj
@@ -5,6 +5,8 @@
    [clojure.java.io :as io]
    [clojure.string :as str]
    [clojure.test :refer :all]
+   [metabase.channel.core :as channel]
+   [metabase.channel.http-test :as channel.http-test]
    [metabase.email :as email]
    [metabase.integrations.slack :as slack]
    [metabase.models
@@ -21,7 +23,6 @@
    [metabase.test.util :as tu]
    [metabase.util :as u]
    [metabase.util.retry :as retry]
-   [metabase.util.retry-test :as rt]
    [toucan2.core :as t2]
    [toucan2.tools.with-temp :as t2.with-temp]))
 
@@ -48,26 +49,36 @@
   invokes
 
     (f pulse)"
-  [{:keys [pulse pulse-card channel card]
-    :or   {channel :email}}
+  [{:keys [pulse pulse-card channel pulse-channel card]
+    :or   {pulse-channel :email}}
    f]
-  (mt/with-temp [Pulse        {pulse-id :id, :as pulse} (->> pulse
-                                                             (merge {:name            "Pulse Name"
-                                                                     :alert_condition "rows"}))
-                 PulseCard    _ (merge {:pulse_id        pulse-id
-                                        :card_id         (u/the-id card)
-                                        :position        0}
-
-                                       pulse-card)
-                 PulseChannel {pc-id :id} (case channel
-                                            :email
-                                            {:pulse_id pulse-id}
-
-                                            :slack
-                                            {:pulse_id     pulse-id
-                                             :channel_type "slack"
-                                             :details      {:channel "#general"}})]
-    (if (= channel :email)
+  (mt/with-temp [:model/Pulse        {pulse-id :id, :as pulse} (->> pulse
+                                                                    (merge {:name            "Pulse Name"
+                                                                            :alert_condition "rows"}))
+                 :model/PulseCard    _ (merge {:pulse_id        pulse-id
+                                               :card_id         (u/the-id card)
+                                               :position        0}
+                                              pulse-card)
+                 ;; channel is currently only used for http
+                 :model/Channel      {chn-id :id} (merge {:type    :channel/http
+                                                          :details {:url         "https://metabase.com/testhttp"
+                                                                    :auth-method "none"}}
+                                                         channel)
+                 :model/PulseChannel {pc-id :id} (case pulse-channel
+                                                   :email
+                                                   {:pulse_id pulse-id
+                                                    :channel_type "email"}
+
+                                                   :slack
+                                                   {:pulse_id     pulse-id
+                                                    :channel_type "slack"
+                                                    :details      {:channel "#general"}}
+
+                                                   :http
+                                                   {:pulse_id     pulse-id
+                                                    :channel_type "http"
+                                                    :channel_id   chn-id})]
+    (if (= pulse-channel :email)
       (t2.with-temp/with-temp [PulseChannelRecipient _ {:user_id          (pulse.test-util/rasta-id)
                                                         :pulse_channel_id pc-id}]
         (f pulse))
@@ -96,9 +107,9 @@
       :assert {:slack (fn [{:keys [pulse-id]} response]
                         (is (= {:sent pulse-id}
                                response)))}})"
-  [{:keys [card pulse pulse-card display fixture], assertions :assert}]
-  {:pre [(map? assertions) ((some-fn :email :slack) assertions)]}
-  (doseq [channel-type [:email :slack]
+  [{:keys [card channel pulse pulse-card display fixture], assertions :assert}]
+  {:pre [(map? assertions) ((some-fn :email :slack :http) assertions)]}
+  (doseq [channel-type [:email :slack :http]
           :let         [f (get assertions channel-type)]
           :when        f]
     (assert (fn? f))
@@ -107,15 +118,14 @@
                                                          :display (or display :line)}
                                                         card)]
         (with-pulse-for-card [{pulse-id :id}
-                              {:card       card-id
-                               :pulse      pulse
-                               :pulse-card pulse-card
-                               :channel    channel-type}]
+                              {:card          card-id
+                               :pulse         pulse
+                               :channel       channel
+                               :pulse-card    pulse-card
+                               :pulse-channel channel-type}]
           (letfn [(thunk* []
                     (f {:card-id card-id, :pulse-id pulse-id}
-                       ((if (= :email channel-type)
-                          :channel/email
-                          :channel/slack)
+                       ((keyword "channel" (name channel-type))
                         (pulse.test-util/with-captured-channel-send-messages!
                           (mt/with-temporary-setting-values [site-url "https://metabase.com/testmb"]
                             (metabase.pulse/send-pulse! (t2/select-one :model/Pulse pulse-id)))))))
@@ -124,7 +134,7 @@
                       (fixture {:card-id card-id, :pulse-id pulse-id} thunk*)
                       (thunk*)))]
             (case channel-type
-              :email (thunk)
+              (:http :email) (thunk)
               :slack (pulse.test-util/slack-test-setup! (thunk)))))))))
 
 (defn- tests!
@@ -186,7 +196,24 @@
                  :attachment-name "image.png"
                  :channel-id      "FOO"
                  :fallback        pulse.test-util/card-name}]}
-              (pulse.test-util/thunk->boolean pulse-results))))}}))
+              (pulse.test-util/thunk->boolean pulse-results))))
+
+     :http
+     (fn [{:keys [card-id pulse-id]} [request]]
+       (let [pulse (t2/select-one :model/Pulse pulse-id)
+             card  (t2/select-one :model/Card card-id)]
+         (is (=? {:body {:type               "alert"
+                         :alert_id           pulse-id
+                         :alert_creator_id   (mt/malli=? int?)
+                         :alert_creator_name (t2/select-one-fn :common_name :model/User (:creator_id pulse))
+                         :data               {:type          "question"
+                                              :question_id   card-id
+                                              :question_name (:name card)
+                                              :question_url  (mt/malli=? [:fn #(str/ends-with? % (str card-id))])
+                                              :visualization (mt/malli=? [:fn #(str/starts-with? % "data:image/png;base64")])
+                                              :raw_data      {:cols ["DATE" "count"], :rows [["2013-01-03T00:00:00Z" 1]]}}
+                         :sent_at            (mt/malli=? :any)}}
+                 request))))}}))
 
 (deftest basic-table-test
   (tests! {:display :table}
@@ -733,52 +760,53 @@
 (defn ^:private test-retry-configuration
   []
   (assoc (#'retry/retry-configuration)
-         :initial-interval-millis 1))
+         :initial-interval-millis 1
+         :max-attempts 2))
 
 (deftest email-notification-retry-test
   (testing "send email succeeds w/o retry"
     (let [test-retry (retry/random-exponential-backoff-retry "test-retry" (test-retry-configuration))]
-      (with-redefs [email/send-email! mt/fake-inbox-email-fn
-                    retry/decorate    (rt/test-retry-decorate-fn test-retry)]
+      (with-redefs [email/send-email!                      mt/fake-inbox-email-fn
+                    retry/random-exponential-backoff-retry (constantly test-retry)]
         (mt/with-temporary-setting-values [email-smtp-host "fake_smtp_host"
                                            email-smtp-port 587]
           (mt/reset-inbox!)
-          (#'metabase.pulse/send-retrying! :channel/email fake-email-notification)
+          (#'metabase.pulse/send-retrying! 1 {:type :channel/email} fake-email-notification)
           (is (= {:numberOfSuccessfulCallsWithoutRetryAttempt 1}
                  (get-positive-retry-metrics test-retry)))
           (is (= 1 (count @mt/inbox)))))))
   (testing "send email succeeds hiding SMTP host not set error"
     (let [test-retry (retry/random-exponential-backoff-retry "test-retry" (test-retry-configuration))]
-      (with-redefs [email/send-email! (fn [& _] (throw (ex-info "Bumm!" {:cause :smtp-host-not-set})))
-                    retry/decorate    (rt/test-retry-decorate-fn test-retry)]
+      (with-redefs [email/send-email!                      (fn [& _] (throw (ex-info "Bumm!" {:cause :smtp-host-not-set})))
+                    retry/random-exponential-backoff-retry (constantly test-retry)]
         (mt/with-temporary-setting-values [email-smtp-host "fake_smtp_host"
                                            email-smtp-port 587]
           (mt/reset-inbox!)
-          (#'metabase.pulse/send-retrying! :channel/email fake-email-notification)
+          (#'metabase.pulse/send-retrying! 1 {:type :channel/email} fake-email-notification)
           (is (= {:numberOfSuccessfulCallsWithoutRetryAttempt 1}
                  (get-positive-retry-metrics test-retry)))
           (is (= 0 (count @mt/inbox)))))))
   (testing "send email fails b/c retry limit"
     (let [retry-config (assoc (test-retry-configuration) :max-attempts 1)
           test-retry (retry/random-exponential-backoff-retry "test-retry" retry-config)]
-      (with-redefs [email/send-email! (tu/works-after 1 mt/fake-inbox-email-fn)
-                    retry/decorate    (rt/test-retry-decorate-fn test-retry)]
+      (with-redefs [email/send-email!                      (tu/works-after 1 mt/fake-inbox-email-fn)
+                    retry/random-exponential-backoff-retry (constantly test-retry)]
         (mt/with-temporary-setting-values [email-smtp-host "fake_smtp_host"
                                            email-smtp-port 587]
           (mt/reset-inbox!)
-          (#'metabase.pulse/send-retrying! :channel/email fake-email-notification)
+          (#'metabase.pulse/send-retrying! 1 {:type :channel/email} fake-email-notification)
           (is (= {:numberOfFailedCallsWithRetryAttempt 1}
                  (get-positive-retry-metrics test-retry)))
           (is (= 0 (count @mt/inbox)))))))
   (testing "send email succeeds w/ retry"
     (let [retry-config (assoc (test-retry-configuration) :max-attempts 2)
           test-retry   (retry/random-exponential-backoff-retry "test-retry" retry-config)]
-      (with-redefs [email/send-email! (tu/works-after 1 mt/fake-inbox-email-fn)
-                    retry/decorate    (rt/test-retry-decorate-fn test-retry)]
+      (with-redefs [email/send-email!                      (tu/works-after 1 mt/fake-inbox-email-fn)
+                    retry/random-exponential-backoff-retry (constantly test-retry)]
         (mt/with-temporary-setting-values [email-smtp-host "fake_smtp_host"
                                            email-smtp-port 587]
           (mt/reset-inbox!)
-          (#'metabase.pulse/send-retrying! :channel/email fake-email-notification)
+          (#'metabase.pulse/send-retrying! 1 {:type :channel/email} fake-email-notification)
           (is (= {:numberOfSuccessfulCallsWithRetryAttempt 1}
                  (get-positive-retry-metrics test-retry)))
           (is (= 1 (count @mt/inbox))))))))
@@ -791,37 +819,100 @@
 (deftest slack-notification-retry-test
   (testing "post slack message succeeds w/o retry"
     (let [test-retry (retry/random-exponential-backoff-retry "test-retry" (test-retry-configuration))]
-      (with-redefs [slack/post-chat-message! (constantly nil)
-                    retry/decorate           (rt/test-retry-decorate-fn test-retry)]
-        (#'metabase.pulse/send-retrying! :channel/slack fake-slack-notification)
+      (with-redefs [retry/random-exponential-backoff-retry (constantly test-retry)
+                    slack/post-chat-message!               (constantly nil)]
+        (#'metabase.pulse/send-retrying! 1 {:type :channel/slack} fake-slack-notification)
         (is (= {:numberOfSuccessfulCallsWithoutRetryAttempt 1}
                (get-positive-retry-metrics test-retry))))))
   (testing "post slack message succeeds hiding token error"
     (let [test-retry (retry/random-exponential-backoff-retry "test-retry" (test-retry-configuration))]
-      (with-redefs [slack/post-chat-message! (fn [& _]
-                                               (throw (ex-info "Invalid token"
-                                                               {:errors {:slack-token "Invalid token"}})))
-                    retry/decorate           (rt/test-retry-decorate-fn test-retry)]
-        (#'metabase.pulse/send-retrying! :channel/slack fake-slack-notification)
+      (with-redefs [retry/random-exponential-backoff-retry (constantly test-retry)
+                    slack/post-chat-message!               (fn [& _]
+                                                             (throw (ex-info "Invalid token"
+                                                                             {:errors {:slack-token "Invalid token"}})))]
+        (#'metabase.pulse/send-retrying! 1 {:type :channel/slack} fake-slack-notification)
         (is (= {:numberOfSuccessfulCallsWithoutRetryAttempt 1}
                (get-positive-retry-metrics test-retry))))))
   (testing "post slack message fails b/c retry limit"
     (let [retry-config (assoc (test-retry-configuration) :max-attempts 1)
           test-retry   (retry/random-exponential-backoff-retry "test-retry" retry-config)]
-      (with-redefs [slack/post-chat-message! (tu/works-after 1 (constantly nil))
-                    retry/decorate           (rt/test-retry-decorate-fn test-retry)]
-        (#'metabase.pulse/send-retrying! :channel/slack fake-slack-notification)
+      (with-redefs [slack/post-chat-message!               (tu/works-after 1 (constantly nil))
+                    retry/random-exponential-backoff-retry (constantly test-retry)]
+        (#'metabase.pulse/send-retrying! 1 {:type :channel/slack} fake-slack-notification)
         (is (= {:numberOfFailedCallsWithRetryAttempt 1}
                (get-positive-retry-metrics test-retry))))))
   (testing "post slack message succeeds with retry"
     (let [retry-config (assoc (test-retry-configuration) :max-attempts 2)
           test-retry   (retry/random-exponential-backoff-retry "test-retry" retry-config)]
-      (with-redefs [slack/post-chat-message! (tu/works-after 1 (constantly nil))
-                    retry/decorate           (rt/test-retry-decorate-fn test-retry)]
-        (#'metabase.pulse/send-retrying! :channel/slack fake-slack-notification)
+      (with-redefs [slack/post-chat-message!               (tu/works-after 1 (constantly nil))
+                    retry/random-exponential-backoff-retry (constantly test-retry)]
+        (#'metabase.pulse/send-retrying! 1 {:type :channel/slack} fake-slack-notification)
         (is (= {:numberOfSuccessfulCallsWithRetryAttempt 1}
                (get-positive-retry-metrics test-retry)))))))
 
+(defn- latest-task-history-entry
+  [task-name]
+  (t2/select-one-fn #(dissoc % :id :started_at :ended_at :duration)
+                    :model/TaskHistory
+                    {:order-by [[:started_at :desc]]
+                     :where [:= :task (name task-name)]}))
+
+(deftest send-channel-record-task-history-test
+  (mt/with-temporary-setting-values [retry-max-attempts     4
+                                     retry-initial-interval 1]
+    (mt/with-model-cleanup [:model/TaskHistory]
+      (let [pulse-id             (rand-int 10000)
+            default-task-details {:pulse-id     pulse-id
+                                  :channel-type "channel/slack"
+                                  :channel-id   nil
+                                  :retry-config {:max-attempts            4
+                                                 :initial-interval-millis 1
+                                                 :multiplier              2.0
+                                                 :randomization-factor    0.1
+                                                 :max-interval-millis     30000}}
+            send!                #(#'metabase.pulse/send-retrying! pulse-id {:type :channel/slack} fake-slack-notification)]
+        (testing "channel send task history task details include retry config"
+          (with-redefs
+           [channel/send! (constantly true)]
+            (send!)
+            (is (= {:task         "channel-send"
+                    :db_id        nil
+                    :status       :success
+                    :task_details default-task-details}
+                   (latest-task-history-entry :channel-send)))))
+
+        (testing "retry errors are recorded when the task eventually succeeds"
+          (with-redefs [channel/send! (tu/works-after 2 (constantly nil))]
+            (send!)
+            (is (=? {:task         "channel-send"
+                     :db_id        nil
+                     :status       :success
+                     :task_details (merge default-task-details
+                                          {:attempted-retries 2
+                                           :retry-errors      (mt/malli=?
+                                                               [:sequential {:min 2 :max 2}
+                                                                [:map
+                                                                 [:trace :any]
+                                                                 [:cause :any]
+                                                                 [:via :any]]])})}
+                    (latest-task-history-entry :channel-send)))))
+
+        (testing "retry errors are recorded when the task eventually fails"
+          (with-redefs [channel/send! (tu/works-after 5 (constantly nil))]
+            (send!)
+            (is (=? {:task         "channel-send"
+                     :db_id        nil
+                     :status       :failed
+                     :task_details {:original-info     default-task-details
+                                    :attempted-retries 4
+                                    :retry-errors      (mt/malli=?
+                                                        [:sequential {:min 4 :max 4}
+                                                         [:map
+                                                          [:trace :any]
+                                                          [:cause :any]
+                                                          [:via :any]]])}}
+                    (latest-task-history-entry :channel-send)))))))))
+
 (deftest alerts-do-not-remove-user-metadata
   (testing "Alerts that exist on a Model shouldn't remove metadata (#35091)."
     (mt/dataset test-data
@@ -856,3 +947,63 @@
                    (t2/select-one-fn
                     (comp #(select-keys % [:display_name :description]) first :result_metadata)
                     :model/Card :id card-id)))))))))
+
+(deftest partial-channel-failure-will-deliver-all-that-success-test
+  (testing "if a pulse is set to send to multiple channels and one of them fail, the other channels should still receive the message"
+    (mt/with-temp
+      [Card         {card-id :id}  (pulse.test-util/checkins-query-card {:breakout [!day.date]
+                                                                         :limit    1})
+       Pulse        {pulse-id :id} {:name "Test Pulse"
+                                    :alert_condition "rows"}
+       PulseCard    _              {:pulse_id pulse-id
+                                    :card_id  card-id}
+       PulseChannel _              {:pulse_id pulse-id
+                                    :channel_type "email"
+                                    :details      {:emails ["foo@metabase.com"]}}
+       PulseChannel _              {:pulse_id     pulse-id
+                                    :channel_type "slack"
+                                    :details      {:channel "#general"}}]
+      (let [original-render-noti (var-get #'channel/render-notification)]
+        (with-redefs [channel/render-notification (fn [& args]
+                                                    (if (= :channel/slacke (:type (first args)))
+                                                      (throw (ex-info "Slack failed" {}))
+                                                      (apply original-render-noti args)))]
+         ;; slack failed but email should still be sent
+          (is (= {:channel/email 1}
+                 (update-vals
+                  (pulse.test-util/with-captured-channel-send-messages!
+                    (metabase.pulse/send-pulse! (t2/select-one :model/Pulse pulse-id)))
+                  count))))))))
+
+(deftest alert-send-to-channel-e2e-test
+  (testing "Send alert to http channel works e2e"
+    (let [requests (atom [])
+          endpoint (channel.http-test/make-route
+                    :post "/test"
+                    (fn [req]
+                      (swap! requests conj req)))]
+      (channel.http-test/with-server [url [endpoint]]
+        (mt/with-temp
+          [:model/Card         card           {:dataset_query (mt/mbql-query orders {:aggregation [[:count]]})}
+           :model/Channel      channel        {:type    :channel/http
+                                               :details {:url         (str url "/test")
+                                                         :auth-method :none}}
+           :model/Pulse        {pulse-id :id} {:name "Test Pulse"
+                                               :alert_condition "rows"}
+           :model/PulseCard    _              {:pulse_id pulse-id
+                                               :card_id  (:id card)}
+           :model/PulseChannel _              {:pulse_id pulse-id
+                                               :channel_type "http"
+                                               :channel_id   (:id channel)}]
+          (metabase.pulse/send-pulse! (t2/select-one :model/Pulse pulse-id))
+          (is (=? {:body {:alert_creator_id   (mt/user->id :rasta)
+                          :alert_creator_name "Rasta Toucan"
+                          :alert_id           pulse-id
+                          :data               {:question_id   (:id card)
+                                               :question_name (mt/malli=? string?)
+                                               :question_url  (mt/malli=? string?)
+                                               :raw_data      {:cols ["count"], :rows [[18760]]},
+                                               :type          "question"
+                                               :visualization (mt/malli=? [:fn #(str/starts-with? % "data:image/png;base64,")])}
+                          :type               "alert"}}
+                  (first @requests))))))))
diff --git a/test/metabase/test/initialize/plugins.clj b/test/metabase/test/initialize/plugins.clj
index fe29d5f26ec..349a81431ea 100644
--- a/test/metabase/test/initialize/plugins.clj
+++ b/test/metabase/test/initialize/plugins.clj
@@ -2,6 +2,7 @@
   (:require
    [clojure.java.io :as io]
    [clojure.tools.reader.edn :as edn]
+   [metabase.channel.core :as channel]
    [metabase.plugins :as plugins]
    [metabase.plugins.initialize :as plugins.init]
    [metabase.test.data.env.impl :as tx.env.impl]
@@ -61,7 +62,8 @@
 
 (defn init! []
   (plugins/load-plugins!)
-  (load-plugin-manifests!))
+  (load-plugin-manifests!)
+  (channel/find-and-load-metabase-channels!))
 
 (defn init-test-drivers!
   "Explicitly initialize the given test `drivers` via plugin manifests. These manifests can live in test_modules (having
diff --git a/test/metabase/test/mock/util.clj b/test/metabase/test/mock/util.clj
index 2598e4faeef..8cc416baa8a 100644
--- a/test/metabase/test/mock/util.clj
+++ b/test/metabase/test/mock/util.clj
@@ -41,7 +41,8 @@
    :schedule_hour  nil
    :schedule_day   nil
    :entity_id      true
-   :enabled        true})
+   :enabled        true
+   :channel_id     nil})
 
 (defn mock-execute-reducible-query [query respond]
   (respond
diff --git a/test/metabase/test/util.clj b/test/metabase/test/util.clj
index f4a7598e7aa..55c76a231f8 100644
--- a/test/metabase/test/util.clj
+++ b/test/metabase/test/util.clj
@@ -123,6 +123,10 @@
    :model/Action
    (fn [_] {:creator_id (rasta-id)})
 
+   :model/Channel
+   (fn [_] (default-timestamped
+            {:name (u.random/random-name)}))
+
    :model/Dashboard
    (fn [_] (default-timestamped
             {:creator_id (rasta-id)
diff --git a/test/metabase/util/retry_test.clj b/test/metabase/util/retry_test.clj
index 6392e4b8ce5..1981e628e43 100644
--- a/test/metabase/util/retry_test.clj
+++ b/test/metabase/util/retry_test.clj
@@ -10,8 +10,7 @@
 (set! *warn-on-reflection* true)
 
 (defn test-retry-decorate-fn
-  "A function that can be used in place of `send-email!`.
-   Put all messages into `inbox` instead of actually sending them."
+  "Decorates a function with a retrying mechanism."
   [retry]
   (fn [f]
     (fn [& args]
diff --git a/test/metabase/util_test.cljc b/test/metabase/util_test.cljc
index b1bdeea1a4d..ad7740ac8d5 100644
--- a/test/metabase/util_test.cljc
+++ b/test/metabase/util_test.cljc
@@ -271,6 +271,13 @@
     "IBIS" "Ibis"
     "Ibis" "Ibis"))
 
+(deftest ^:parallel truncate-test
+  (are [s n expected] (= expected
+                         (u/truncate s n))
+    "string" 10 "string"
+    "string" 3  "str"
+    "string" 0  ""))
+
 #?(:clj
    (deftest capitalize-en-turkish-test
      (mt/with-locale "tr"
-- 
GitLab