diff --git a/.clj-kondo/config.edn b/.clj-kondo/config.edn
index 0caacded8689f7deea51bf0aae3850c30d82e7d4..0155cb3a7d57854395cf6a9fd9a686e5017e0646 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 3a7b1cc79526d12a789e1cdfa58d4184786307ef..ac82a79947b3eadcc52e8f8b7dcbf59100879f58 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 a73383c30d93374e7603a5c77a9f50f1f19b53f8..8fbf2aa3d7480110a91c14e7303e9e63a2bc3f96 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 3b8fdaf83925a4a248fb51a4aaee3f02e07bf01f..cb65c56e59e57a487e210b5704f659b7992c3b57 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 0000000000000000000000000000000000000000..4ca7ceb109c10299cb152872a4b0a52274e01c8f
--- /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 5bc9dbd5c52d29e192cd4c783178ff85b18b42f3..6894d06aabe95ab980c88a86e6ed3c8d5357849c 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 2de559d36594b352ca0ae62fe92bded28f5d05a4..92195ce1bbab238acf2beb826ea5f4a2da303d6a 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 dac196e0efe02793234a1d1b07d9f8adeef40304..f1f5c73a1056a64c295cd561d0cbf6ea3e850be8 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 d4e97426853fc8a5bf12b7a244000f1da1b93b05..5cffe5dbb0b1e77b6822ae63dd8b6386ce2ccf6d 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 856fdaa81a3020e89c9ee2ac9f4072537229b103..2c2d29039db3d4ef6b43beb7a62641b66dca2af2 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 579e23f063d52cf81833939472b8269022f18fe8..c61791cad6a899b9446081bb68c8c34a2cd97acf 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 f4aa4523d496c3e2595142e70e801a81dd02164b..7241ff270122f2160946d39d7c6111adf82d8336 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 0000000000000000000000000000000000000000..9d78cd5cb3822572e991116dbd9cadfab5930833
--- /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 4b1d9c0963d625578dbfc5e7f66576710e4b06c7..551b7dcd7d4a09aadee72e337efdb8e10c78c62a 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 e9545a3b68c39a4322eefdd2276e0218cef1e00c..c1ca5df0c3d125302b3e25fb32494892147efbad 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 2d665fa81233a58d1b903e8f0d3b367b7ae4fee5..ba513d48b4aa68ca40011df8a3f39ccdf3d0bc06 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 f689bbf159e09e8a345e59515ce271d4b27b0cd1..6d716eb09d6368000c0237f31ab02c0a62aadd4c 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 ceb135b118b713d488392659ff40ce7e27ad68f6..b50f1560dbe663991ec50d9b7ad68da74d934d2a 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 0000000000000000000000000000000000000000..b0fa5aabee383a8189a29ec08c10ae73094d2f31
--- /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 660833278541ac39f74006ea819d42afabf578e4..07aaf9aa7e9e5bd355c18c023bd43681ad53fd67 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 0000000000000000000000000000000000000000..5c84ad5a65de199ab6443b51144f751051d482e4
--- /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 0000000000000000000000000000000000000000..33209be29161bff1de24929cc2a1b2e83a1246d7
--- /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 0000000000000000000000000000000000000000..6c67648507a23f3822405f96c389b144cc369aaf
--- /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 0000000000000000000000000000000000000000..bb7d150eab6581739e63812db200fdc2295f0183
--- /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 0000000000000000000000000000000000000000..3bc79b676270353eb83237dd283a53677a487487
--- /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 0000000000000000000000000000000000000000..dbb58618c7cba67d0fb061083fcfcec3337a088a
--- /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 0000000000000000000000000000000000000000..9ad0bf0a488bc5e920d07b3e62b3ebda40ef1379
--- /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 2c462689f7f59a9db1ddcf6b783d93b4f15e9ced..f7afc9b620a0111f14c4f2dd8c8d029cee0e3b7b 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 1024df6da8387d9f3e9b8db9c44041e38efd2366..9fe5572cac3c99b40008de906c3cdb3450538cc0 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 0000000000000000000000000000000000000000..e3280edaa22f0810e8375da3bd33db05f401ba8e
--- /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 0dac02bbd191067136e34b88e2f41c2a33272431..36899b1d267af91730b3f48d03a83a17d5e91037 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 af654a77e9601d337e7d9433c55d18378ca327a2..40ab1d4bc5cf598eaddcffd14520abcc22d2ed58 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 9a1801a506db9541626829a1cd8316d36c6d0601..d7104b2a5197f110ca2d4b988c4882b2b3a5fb08 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 0000000000000000000000000000000000000000..23ff4d0a916e600e9f60598ff261151fdf3b1d56
--- /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 0000000000000000000000000000000000000000..15db4a83929e50a1d8eb594c09b6e26a7bee1230
--- /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 0000000000000000000000000000000000000000..0ee7e361622737ebbb49e1164f818a15f7174d78
--- /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 0000000000000000000000000000000000000000..ae6e73a302b9d830c3471278ed0e66a8133f144c
--- /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 0000000000000000000000000000000000000000..22d92cb3f10d294da192dbf77b2ee0c46120b862
--- /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 cd36d8d229d8c4ecdd0ff979f812999a137be63f..4c65bc5d455283fa7f0ffba4758e6128dc51b58a 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 0000000000000000000000000000000000000000..8babf46ed0ff33a48ee3ee5ad1b24d6a57cf2e70
--- /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 0000000000000000000000000000000000000000..587e17971a480a7e99b58417c9d92e88b797cbb6
--- /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 0000000000000000000000000000000000000000..022e81019f341b53fb21cde3c0047990c9131a37
--- /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 56e31f35e53c3ea8a8bffba7358c3cc4e0b9683b..206ae1aeea5ce068c70a0856de4c78c0986bffac 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 e4e41df7f244f1330e3d1cffd28f795b37304985..fe372a57b6c407bf470fb86c865035d4900af19f 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 0000000000000000000000000000000000000000..a312006132498661c968e0977c9fb37772e8f416
--- /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 2e1a461039c5a82532eb9ac53c9b7f07e90e5ac7..23f0a8c5ec516c6d7147f85964d250b0b32f3da4 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 0000000000000000000000000000000000000000..9b60a58ac370b033a9a354017798fc399dfb25b6
--- /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 0000000000000000000000000000000000000000..710450a95a6513b85d1869fac07b53b359d17697
--- /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 3f5331b34b414b6a3f83d6d1f04c33e04a9f7c4c..6776fda97524f923766b8825a73dcdfc81f19934 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 0000000000000000000000000000000000000000..4adeb30d8f1ba40688d9694dd3621ed3df833535
--- /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 0000000000000000000000000000000000000000..aa66b130182a97b612377776c91bfe4fbbfd55ee
--- /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 0000000000000000000000000000000000000000..f11074f772f92a2ba044270e6dac45b3539f8c9f
--- /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 2b6078546216d4c15f9e93e1a0255e3340fb80a5..1cb7d6a9353250053298fc9e0658527f705e0ea3 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 353f8c4c2a38c8f038aee913b1ca55509b092ff7..d30e9dfcbf40a987af43180e9c1ddb796d3379a2 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 a17c46fa1cc2a3ee4e6c7e848d5140748a5ecad9..4351d757369bbca812cd6f71065d832ed0b7e479 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 0000000000000000000000000000000000000000..7bb70776055f5603c3b42972cba24db407552361
--- /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 6b6e772aac0d47710fa5217c4fa37498cc27b27e..81e0aa29e76836c9931c77d587c52ed4d65088e1 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 86f101abfb9ab4073716382b64765f231abb02b6..4f4a0bea4a73809fc32224109905bda39c1bd06f 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 a3b9945dbc392e795fdf8fd6850cf28d1c0870c9..c2e95d7473fc3a874c217aad9e3464b6a481680f 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 7789633f82dfd31b31df58664fdb936989ebdb97..75b054076a47befde686bc78d41bb2aba919338d 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 0000000000000000000000000000000000000000..aba212dd81bd3bc74f329c0b89be87954df60d46
--- /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 1b5700c33d6d0ea59332fc49ac8bc9228ec00356..0cae0274e908fa213d4118aaf86d2e9ad52f743d 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 dc16902ce5ee92d598306b6eff1f9ea3c962dbf7..ddb5b09fae09354284532b9ce446f08e545b8b19 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 1651e9e51f321a6332926b6aa847c28ad144a5a8..e313a698e9ae3810139200a84fa2995565f981be 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 7c05d914eebe7b9828f9f11857a98717ea638aac..42c371e258505002d7389e660d93607409f1619f 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 bf79071e5cefa89a5b62fe9d31905071366304ba..37a9d82b7c9a97f39fd9bfcac6126eaed1e82ef8 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 e87a78a37d1259e10f08c38d452029a728d87094..281f2ae15ea21fe5cdd721cacbfa4d72536d378b 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 0000000000000000000000000000000000000000..32ac7d8e2eb7f0d5232ff062830ca91acd540441
--- /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 a9b2ca853419dc14562dcb6c64f15731e3e825da..80e755f7b1261ea1f3bb4892fa8bdb18562a5482 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 8cd5a1a9ce1c96bad262038ede3f8ef13c2468d7..b94d120c8db45dd4467da7e9ea04766013197d40 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 edff5c2d4acfe3ae37a481a22e717f07faaff89a..4cf4b9bba925d79385d705b15b58fc80aef63108 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 ae5565f52b12fd43c344918165c5201e912b07f3..1e4299e82a1e5d996362c1522253595d412faf69 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 71d039e1909b8272e551c65b76589f7a4b26cc4f..d7293dd4b99f705842c4ac5a40f4407c79269a56 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 13ea2e643e134f3267f576ec020d995384b3b195..a23b3e04811413de3a99fa46411bcb83ccd32a4c 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 eca687eb5ed6a8716b85c26146f8172b009aa088..3e93cd05f291757d6518413fc5e4ad2c210a74c4 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 7eb276054442e5b2dad868c7be51e08b9e77203b..3d126719037489f629de955b35cd0c2abe423260 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 aa0d044a2e582b5c8e1c3ef2473d4fc1c88fd005..0346357eaf71da65c30396d3c4faa45e73bc377f 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 41ad733371c967406f2d4e36152533c181105e4b..dc424376deedbdf8b428b0a1185c97389f53e312 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 075194ebf5c766ad9c76861d050aff9d7400e26d..f94fa75984f2cf9a6f0f54eb894cb6f0752678a0 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 097719fd5616fc6060e25a706432538bce223c29..0bf7bb0d241055098485779ae2bdc6eacf332cee 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 0000000000000000000000000000000000000000..600d9907a2d10eeab8c8e2d6d7c031866cf4d8d0
--- /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 df7db4f35695144f0ef555bb1634f233effb0b66..9e294d287c0e30e91e6889998383a7cf356e04dc 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 499572bc7fa720bc268fc4c640e7f66ba955a7ec..4bb3efd015a574e0819c14ee74094138d54ac7dd 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 0000000000000000000000000000000000000000..1f84e72d5be18017bf1add508f2af77bc42efa84
--- /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 4bfab303a3cb63b9de66505e1d599ef47cce0dd6..c13eae3638689fb4b5098a5e7b7cf095ab33381e 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 46fec88def31e26579f400bc7f7730ee32ed2179..b90773deecf2ebb0bbdf4e3d2223c8252b89eb39 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 0000000000000000000000000000000000000000..179b86c31c79cdb783ff10df86fda82788334d9d
--- /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 23e4d47b00ca4a8d529a61642fc191bc1faefb2c..1734c33c97c1f9cf270d85143879da1647a8f6e0 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 903b8a38b3f3fe9d48bf2564dad94463bc04272b..90d8b57440c714e1c31f0d10931d4146213fad51 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 cef463fd5ba85f19fafca7b329040bf9cb33b232..585187cb71b075a58de372f97e65dd82ae83fe8b 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 9af15ea923f5bf1314fdeb7d5f474ad2d17bd22d..73c683ce9b0a4052d073ffecc72db1fd73c682e9 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 2a1cf19a6de35afb98357283c1c082a4cf80cc52..eba9e29a483552406c474f7893e05157a9fb1f38 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 fe29d5f26ecaea84ebaee8b1e0b7e9b6e8f1f204..349a81431eaf7033c985d0d8aa6768f51d1087ed 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 2598e4faeef93bb9353234a915b34f73e764eb68..8cc416baa8a3d9932acdb0453d099af9fdb1ec0c 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 f4a7598e7aa00a104c62c952b19054c138e97f36..55c76a231f8f26750acae274f8234cbeed2462b9 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 6392e4b8ce56c086690dc695a1e4c8d50513703f..1981e628e438350f8d239fadc2e6f408252612ce 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 b1bdeea1a4d9675a3fc588655a70f80d5a8c0a91..ad7740ac8d5946564ffe3a866171899c59b09bae 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"