Repository: runatlantis/atlantis (language: go)

Issue:
Feature reqeuest: Generic webhook for notifications, instead of slack-token
Would be great to support custom webhook, instead of:
https://www.runatlantis.io/docs/server-configuration.html#slack-token

`--webhook-url`  ( Example: 'https://example.com/hook?title=foo&body=bar' )
`--webhook-payload` ( POST: '{"title":"foo","body":"bar"}' )
`--webhook-headers` ( Security headers: 'x-custom-header-token=foobar' )

It would work for slack,msteams,mattermost, etc...

Thanks


Discussion / hints:
Closed due to inactivity, if this still needed comment and we will reopen but check the latest docs/features first.
Thanks.
this is a very good idea actually.
This is stiil a needful feature

Fix patch:
diff --git a/cmd/server.go b/cmd/server.go
index aa8581e705..b71c5b2da3 100644
--- a/cmd/server.go
+++ b/cmd/server.go
@@ -153,6 +153,7 @@ const (
 	TFELocalExecutionModeFlag        = "tfe-local-execution-mode"
 	TFETokenFlag                     = "tfe-token"
 	WriteGitCredsFlag                = "write-git-creds" // nolint: gosec
+	WebhookHttpHeaders               = "webhook-http-headers"
 	WebBasicAuthFlag                 = "web-basic-auth"
 	WebUsernameFlag                  = "web-username"
 	WebPasswordFlag                  = "web-password"
@@ -460,6 +461,12 @@ var stringFlags = map[string]stringFlag{
 		description:  "Name used to identify Atlantis for pull request statuses.",
 		defaultValue: DefaultVCSStatusName,
 	},
+	WebhookHttpHeaders: {
+		description: "Additional headers added to each HTTP POST payload when using HTTP webhooks provided as a JSON string." +
+			" The map key is the header name and the value is the header value (string) or values (array of string)." +
+			" For example: `{\"Authorization\":\"Bearer some-token\",\"X-Custom-Header\":[\"value1\",\"value2\"]}`.",
+		defaultValue: "",
+	},
 	WebUsernameFlag: {
 		description:  "Username used for Web Basic Authentication on Atlantis HTTP Middleware",
 		defaultValue: DefaultWebUsername,
@@ -1069,6 +1076,10 @@ func (s *ServerCmd) validate(userConfig server.UserConfig) error {
 		return errors.Wrapf(err, "invalid --%s", AllowCommandsFlag)
 	}
 
+	if _, err := userConfig.ToWebhookHttpHeaders(); err != nil {
+		return errors.Wrapf(err, "invalid --%s", WebhookHttpHeaders)
+	}
+
 	return nil
 }
 
diff --git a/runatlantis.io/.vitepress/sidebars.ts b/runatlantis.io/.vitepress/sidebars.ts
index 5bcabfc4bc..9afc20f780 100644
--- a/runatlantis.io/.vitepress/sidebars.ts
+++ b/runatlantis.io/.vitepress/sidebars.ts
@@ -44,7 +44,7 @@ const en = [
           { text: "Checkout Strategy", link: "/docs/checkout-strategy" },
           { text: "Terraform Versions", link: "/docs/terraform-versions" },
           { text: "Terraform Cloud", link: "/docs/terraform-cloud" },
-          { text: "Using Slack Hooks", link: "/docs/using-slack-hooks" },
+          { text: "Sending Notifications via Webhooks", link: "/docs/sending-notifications-via-webhooks" },
           { text: "Stats", link: "/docs/stats" },
           { text: "FAQ", link: "/docs/faq" },
         ]
diff --git a/runatlantis.io/docs/sending-notifications-via-webhooks.md b/runatlantis.io/docs/sending-notifications-via-webhooks.md
new file mode 100644
index 0000000000..9272a25c89
--- /dev/null
+++ b/runatlantis.io/docs/sending-notifications-via-webhooks.md
@@ -0,0 +1,151 @@
+# Sending notifications via webhooks
+
+It is possible to send notifications to external systems whenever an apply is being done.
+
+You can make requests to any HTTP endpoint or send messages directly to your Slack channel.
+
+::: tip NOTE
+Currently only `apply` events are supported.
+:::
+
+## Configuration
+
+Webhooks are configured in Atlantis [server-side configuration](server-configuration.md).
+There can be many webhooks: sending notifications to different destinations or for different
+workspaces/branches. Here is example configuration to send Slack messages for every apply:
+
+```yaml
+webhooks:
+- event: apply
+  kind: slack
+  channel: my-channel-id
+```
+
+If you are deploying Atlantis as a Helm chart, this can be implemented via the `config` parameter available for [chart customizations](https://github.com/runatlantis/helm-charts#customization):
+
+```yaml
+## Use Server Side Config,
+## ref: https://www.runatlantis.io/docs/server-configuration.html
+config: |
+   ---
+   webhooks:
+     - event: apply
+       kind: slack
+       channel: my-channel-id
+```
+
+### Filter on workspace/branch
+
+To limit notifications to particular workspaces or branches, use `workspace-regex` or `branch-regex` parameters.
+If the workspace **and** branch matches respective regex, an event will be sent. Note that empty regular expression
+(a result of unset parameter) matches every string.
+
+## Using HTTP webhooks
+
+You can send POST requests with JSON payload to any HTTP/HTTPS server.
+
+### Configuring Atlantis
+
+In your Atlantis [server-side configuration](server-configuration.md) you can add the following:
+
+```yaml
+webhooks:
+- event: apply
+  kind: http
+  url: https://example.com/hooks
+```
+
+The `apply` event information will be POSTed to `https://example.com/hooks`.
+
+You can supply any additional headers with `--webhook-http-headers` parameter (or environment variable),
+for example for authentication purposes. See [webhook-http-headers](server-configuration.md#webhook-http-headers) for details.
+
+### JSON payload
+
+The payload is a JSON-marshalled [ApplyResult](https://pkg.go.dev/github.com/runatlantis/atlantis/server/events/webhooks#ApplyResult) struct.
+
+Example payload:
+
+```json
+{
+  "Workspace": "default",
+  "Repo": {
+    "FullName": "octocat/Hello-World",
+    "Owner": "octocat",
+    "Name": "Hello-World",
+    "CloneURL": "https://:@github.com/octocat/Hello-World.git",
+    "SanitizedCloneURL": "https://:<redacted>@github.com/octocat/Hello-World.git",
+    "VCSHost": {
+      "Hostname": "github.com",
+      "Type": 0
+    }
+  },
+  "Pull": {
+    "Num": 2137,
+    "HeadCommit": "7fd1a60b01f91b314f59955a4e4d4e80d8edf11d",
+    "URL": "https://github.com/octocat/Hello-World/pull/2137",
+    "HeadBranch": "feature/some-branch",
+    "BaseBranch": "main",
+    "Author": "octocat",
+    "State": 0,
+    "BaseRepo": {
+      "FullName": "octocat/Hello-World",
+      "Owner": "octocat",
+      "Name": "Hello-World",
+      "CloneURL": "https://:@github.com/octocat/Hello-World.git",
+      "SanitizedCloneURL": "https://:<redacted>@github.com/octocat/Hello-World.git",
+      "VCSHost": {
+        "Hostname": "github.com",
+        "Type": 0
+      }
+    }
+  },
+  "User": {
+    "Username": "octocat",
+    "Teams": null
+  },
+  "Success": true,
+  "Directory": "terraform/example", 
+  "ProjectName": "example-project"
+}
+```
+
+## Using Slack hooks
+
+For this you'll need to:
+
+* Create a Bot user in Slack
+* Configure Atlantis to send notifications to Slack.
+
+### Configuring Slack for Atlantis
+
+* Go to [Slack: Apps](https://api.slack.com/apps)
+* Click the `Create New App` button
+* Select `From scratch` in the dialog that opens
+* Give it a name, e.g. `atlantis-bot`.
+* Select your Slack workspace
+* Click `Create App`
+* On the left go to `oAuth & Permissions`
+* Scroll down to Scopes | Bot Token Scopes and add the following OAuth scopes:
+  * `channels:read`
+  * `chat:write`
+  * `groups:read`
+  * `incoming-webhook`
+  * `mpim:read`
+* Install the app onto your Slack workspace
+* Copy the `Bot User OAuth Token` and provide it to Atlantis by using `--slack-token=xoxb-xxxxxxxxxxx` or via the environment `ATLANTIS_SLACK_TOKEN=xoxb-xxxxxxxxxxx`.
+* Create a channel in your Slack workspace (e.g. `my-channel`) or use existing
+* Add the app to Created channel or existing channel ( click channel name then tab integrations, there Click "Add apps"
+
+### Configuring Atlantis
+
+After following the above steps it is time to configure Atlantis. Assuming you have already provided the `slack-token` (via parameter or environment variable) you can now instruct Atlantis to send `apply` events to Slack.
+
+In your Atlantis [server-side configuration](server-configuration.md) you can now add the following:
+
+```yaml
+webhooks:
+- event: apply
+  kind: slack
+  channel: my-channel-id
+```
diff --git a/runatlantis.io/docs/server-configuration.md b/runatlantis.io/docs/server-configuration.md
index 3db678573d..87e0d10605 100644
--- a/runatlantis.io/docs/server-configuration.md
+++ b/runatlantis.io/docs/server-configuration.md
@@ -1234,7 +1234,7 @@ This is useful when you have many projects and want to keep the pull request cle
   ATLANTIS_SLACK_TOKEN='token'
   ```
 
-  API token for Slack notifications. See [Using Slack hooks](using-slack-hooks.md).
+  API token for Slack notifications. See [Using Slack hooks](sending-notifications-via-webhooks.md#using-slack-hooks).
 
 ### `--ssl-cert-file`
 
@@ -1405,6 +1405,18 @@ The effect of the race condition is more evident when using parallel configurati
 
   Username used for Basic Authentication on the Atlantis web service. Defaults to `atlantis`.
 
+### `--webhook-http-headers`
+
+  ```bash
+  atlantis server --webhook-http-headers='{"Authorization":"Bearer some-token","X-Custom-Header":["value1","value2"]}'
+  # or
+  ATLANTIS_WEBHOOK_HTTP_HEADERS='{"Authorization":"Bearer some-token","X-Custom-Header":["value1","value2"]}'
+  ```
+
+  Additional headers added to each HTTP POST payload when using [http webhooks](sending-notifications-via-webhooks.md#using-http-webhooks)
+  provided as a JSON string. The map key is the header name and the value is the header value
+  (string) or values (array of string).
+
 ### `--websocket-check-origin`
 
   ```bash
diff --git a/runatlantis.io/docs/using-slack-hooks.md b/runatlantis.io/docs/using-slack-hooks.md
deleted file mode 100644
index 572b0857f8..0000000000
--- a/runatlantis.io/docs/using-slack-hooks.md
+++ /dev/null
@@ -1,64 +0,0 @@
-# Using Slack hooks
-
-It is possible to use Slack to send notifications to your Slack channel whenever an apply is being done.
-
-::: tip NOTE
-Currently only `apply` events are supported.
-:::
-
-For this you'll need to:
-
-* Create a Bot user in Slack
-* Configure Atlantis to send notifications to Slack.
-
-## Configuring Slack for Atlantis
-
-* Go to [Slack: Apps](https://api.slack.com/apps)
-* Click the `Create New App` button
-* Select `From scratch` in the dialog that opens
-* Give it a name, e.g. `atlantis-bot`.
-* Select your Slack workspace
-* Click `Create App`
-* On the left go to `oAuth & Permissions`
-* Scroll down to Scopes | Bot Token Scopes and add the following OAuth scopes:
-  * `channels:read`
-  * `chat:write`
-  * `groups:read`
-  * `incoming-webhook`
-  * `mpim:read`
-* Install the app onto your Slack workspace
-* Copy the `Bot User OAuth Token` and provide it to Atlantis by using `--slack-token=xoxb-xxxxxxxxxxx` or via the environment `ATLANTIS_SLACK_TOKEN=xoxb-xxxxxxxxxxx`.
-* Create a channel in your Slack workspace (e.g. `my-channel`) or use existing
-* Add the app to Created channel or existing channel ( click channel name then tab integrations, there Click "Add apps"
-
-## Configuring Atlantis
-
-After following the above steps it is time to configure Atlantis. Assuming you have already provided the `slack-token` (via parameter or environment variable) you can now instruct Atlantis to send `apply` events to Slack.
-
-In your Atlantis configuration you can now add the following:
-
-```yaml
-webhooks:
-- event: apply
-  workspace-regex: .*
-  branch-regex: .*
-  kind: slack
-  channel: my-channel-id
-```
-
-If you are deploying Atlantis as a Helm chart, this can be implemented via the `config` parameter available for [chart customizations](https://github.com/runatlantis/helm-charts#customization):
-
-```yaml
-## Use Server Side Config,
-## ref: https://www.runatlantis.io/docs/server-configuration.html
-config: |
-   ---
-   webhooks:
-     - event: apply
-       workspace-regex: .*
-       branch-regex: .*
-       kind: slack
-       channel: my-channel-id
-```
-
-The `apply` event information will be sent to the `my-channel-id` Slack channel.
diff --git a/server/events/project_command_runner.go b/server/events/project_command_runner.go
index 76f9ba9202..79e1d7899c 100644
--- a/server/events/project_command_runner.go
+++ b/server/events/project_command_runner.go
@@ -660,12 +660,13 @@ func (p *DefaultProjectCommandRunner) doApply(ctx command.ProjectContext) (apply
 	outputs, err := p.runSteps(ctx.Steps, ctx, absPath)
 
 	p.Webhooks.Send(ctx.Log, webhooks.ApplyResult{ // nolint: errcheck
-		Workspace: ctx.Workspace,
-		User:      ctx.User,
-		Repo:      ctx.Pull.BaseRepo,
-		Pull:      ctx.Pull,
-		Success:   err == nil,
-		Directory: ctx.RepoRelDir,
+		Workspace:   ctx.Workspace,
+		User:        ctx.User,
+		Repo:        ctx.Pull.BaseRepo,
+		Pull:        ctx.Pull,
+		Success:     err == nil,
+		Directory:   ctx.RepoRelDir,
+		ProjectName: ctx.ProjectName,
 	})
 
 	if err != nil {
diff --git a/server/events/webhooks/http.go b/server/events/webhooks/http.go
new file mode 100644
index 0000000000..6f540ac154
--- /dev/null
+++ b/server/events/webhooks/http.go
@@ -0,0 +1,65 @@
+package webhooks
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"regexp"
+
+	"github.com/pkg/errors"
+	"github.com/runatlantis/atlantis/server/logging"
+)
+
+// HttpWebhook sends webhooks to any HTTP destination.
+type HttpWebhook struct {
+	Client         *HttpClient
+	WorkspaceRegex *regexp.Regexp
+	BranchRegex    *regexp.Regexp
+	URL            string
+}
+
+// Send sends the webhook to URL if workspace and branch matches their respective regex.
+func (h *HttpWebhook) Send(_ logging.SimpleLogging, applyResult ApplyResult) error {
+	if !h.WorkspaceRegex.MatchString(applyResult.Workspace) || !h.BranchRegex.MatchString(applyResult.Pull.BaseBranch) {
+		return nil
+	}
+	if err := h.doSend(applyResult); err != nil {
+		return errors.Wrap(err, fmt.Sprintf("sending webhook to %q", h.URL))
+	}
+	return nil
+}
+
+func (h *HttpWebhook) doSend(applyResult ApplyResult) error {
+	body, err := json.Marshal(applyResult)
+	if err != nil {
+		return err
+	}
+	req, err := http.NewRequest("POST", h.URL, bytes.NewBuffer(body))
+	if err != nil {
+		return err
+	}
+	req.Header.Set("Content-Type", "application/json")
+	for header, values := range h.Client.Headers {
+		for _, value := range values {
+			req.Header.Add(header, value)
+		}
+	}
+	resp, err := h.Client.Client.Do(req)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode != http.StatusOK {
+		respBody, _ := io.ReadAll(resp.Body)
+		return fmt.Errorf("returned status code %d with response %q", resp.StatusCode, respBody)
+	}
+	return nil
+}
+
+// HttpClient wraps http.Client allowing to add arbitrary Headers to a request.
+type HttpClient struct {
+	Client  *http.Client
+	Headers map[string][]string
+}
diff --git a/server/events/webhooks/webhooks.go b/server/events/webhooks/webhooks.go
index c4b43239a7..09d74371ed 100644
--- a/server/events/webhooks/webhooks.go
+++ b/server/events/webhooks/webhooks.go
@@ -24,6 +24,7 @@ import (
 )
 
 const SlackKind = "slack"
+const HttpKind = "http"
 const ApplyEvent = "apply"
 
 //go:generate pegomock generate --package mocks -o mocks/mock_sender.go Sender
@@ -36,12 +37,13 @@ type Sender interface {
 
 // ApplyResult is the result of a terraform apply.
 type ApplyResult struct {
-	Workspace string
-	Repo      models.Repo
-	Pull      models.PullRequest
-	User      models.User
-	Success   bool
-	Directory string
+	Workspace   string
+	Repo        models.Repo
+	Pull        models.PullRequest
+	User        models.User
+	Success     bool
+	Directory   string
+	ProjectName string
 }
 
 // MultiWebhookSender sends multiple webhooks for each one it's configured for.
@@ -55,9 +57,15 @@ type Config struct {
 	BranchRegex    string
 	Kind           string
 	Channel        string
+	URL            string
 }
 
-func NewMultiWebhookSender(configs []Config, client SlackClient) (*MultiWebhookSender, error) {
+type Clients struct {
+	Slack SlackClient
+	Http  *HttpClient
+}
+
+func NewMultiWebhookSender(configs []Config, clients Clients) (*MultiWebhookSender, error) {
 	var webhooks []Sender
 	for _, c := range configs {
 		wr, err := regexp.Compile(c.WorkspaceRegex)
@@ -76,19 +84,30 @@ func NewMultiWebhookSender(configs []Config, client SlackClient) (*MultiWebhookS
 		}
 		switch c.Kind {
 		case SlackKind:
-			if !client.TokenIsSet() {
+			if !clients.Slack.TokenIsSet() {
 				return nil, errors.New("must specify top-level \"slack-token\" if using a webhook of \"kind: slack\"")
 			}
 			if c.Channel == "" {
 				return nil, errors.New("must specify \"channel\" if using a webhook of \"kind: slack\"")
 			}
-			slack, err := NewSlack(wr, br, c.Channel, client)
+			slack, err := NewSlack(wr, br, c.Channel, clients.Slack)
 			if err != nil {
 				return nil, err
 			}
 			webhooks = append(webhooks, slack)
+		case HttpKind:
+			if c.URL == "" {
+				return nil, errors.New("must specify \"url\" if using a webhook of \"kind: http\"")
+			}
+			httpWebhook := &HttpWebhook{
+				Client:         clients.Http,
+				WorkspaceRegex: wr,
+				BranchRegex:    br,
+				URL:            c.URL,
+			}
+			webhooks = append(webhooks, httpWebhook)
 		default:
-			return nil, fmt.Errorf("\"kind: %s\" not supported. Only \"kind: %s\" is supported right now", c.Kind, SlackKind)
+			return nil, fmt.Errorf("\"kind: %s\" not supported. Only \"kind: %s\" and \"kind: %s\" are supported right now", c.Kind, SlackKind, HttpKind)
 		}
 	}
 
@@ -101,7 +120,7 @@ func NewMultiWebhookSender(configs []Config, client SlackClient) (*MultiWebhookS
 func (w *MultiWebhookSender) Send(log logging.SimpleLogging, result ApplyResult) error {
 	for _, w := range w.Webhooks {
 		if err := w.Send(log, result); err != nil {
-			log.Warn("error sending slack webhook: %s", err)
+			log.Warn("error sending webhook: %s", err)
 		}
 	}
 	return nil
diff --git a/server/server.go b/server/server.go
index 6f4e31c497..3bd9f6fb51 100644
--- a/server/server.go
+++ b/server/server.go
@@ -149,11 +149,14 @@ type WebhookConfig struct {
 	// that is being modified for this event. If the regex matches, we'll
 	// send the webhook, ex. "main.*".
 	BranchRegex string `mapstructure:"branch-regex"`
-	// Kind is the type of webhook we should send, ex. slack.
+	// Kind is the type of webhook we should send, ex. slack or http.
 	Kind string `mapstructure:"kind"`
 	// Channel is the channel to send this webhook to. It only applies to
 	// slack webhooks. Should be without '#'.
 	Channel string `mapstructure:"channel"`
+	// URL is the URL where to deliver this webhook. It only applies to
+	// http webhooks.
+	URL string `mapstructure:"url"`
 }
 
 //go:embed static
@@ -379,10 +382,21 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) {
 			Event:          c.Event,
 			Kind:           c.Kind,
 			WorkspaceRegex: c.WorkspaceRegex,
+			URL:            c.URL,
 		}
 		webhooksConfig = append(webhooksConfig, config)
 	}
-	webhooksManager, err := webhooks.NewMultiWebhookSender(webhooksConfig, webhooks.NewSlackClient(userConfig.SlackToken))
+	webhookHeaders, err := userConfig.ToWebhookHttpHeaders()
+	if err != nil {
+		return nil, errors.Wrap(err, "parsing webhook http headers")
+	}
+	webhooksManager, err := webhooks.NewMultiWebhookSender(
+		webhooksConfig,
+		webhooks.Clients{
+			Slack: webhooks.NewSlackClient(userConfig.SlackToken),
+			Http:  &webhooks.HttpClient{Client: http.DefaultClient, Headers: webhookHeaders},
+		},
+	)
 	if err != nil {
 		return nil, errors.Wrap(err, "initializing webhooks")
 	}
diff --git a/server/user_config.go b/server/user_config.go
index 9cd4f54675..3f27aa323d 100644
--- a/server/user_config.go
+++ b/server/user_config.go
@@ -1,8 +1,11 @@
 package server
 
 import (
+	"encoding/json"
 	"strings"
 
+	"github.com/pkg/errors"
+
 	"github.com/runatlantis/atlantis/server/events/command"
 	"github.com/runatlantis/atlantis/server/logging"
 )
@@ -120,6 +123,7 @@ type UserConfig struct {
 	DefaultTFDistribution      string          `mapstructure:"default-tf-distribution"`
 	DefaultTFVersion           string          `mapstructure:"default-tf-version"`
 	Webhooks                   []WebhookConfig `mapstructure:"webhooks" flag:"false"`
+	WebhookHttpHeaders         string          `mapstructure:"webhook-http-headers"`
 	WebBasicAuth               bool            `mapstructure:"web-basic-auth"`
 	WebUsername                string          `mapstructure:"web-username"`
 	WebPassword                string          `mapstructure:"web-password"`
@@ -152,6 +156,37 @@ func (u UserConfig) ToAllowCommandNames() ([]command.Name, error) {
 	return allowCommands, nil
 }
 
+// ToWebhookHttpHeaders parses WebhookHttpHeaders into a map of HTTP headers.
+func (u UserConfig) ToWebhookHttpHeaders() (map[string][]string, error) {
+	if u.WebhookHttpHeaders == "" {
+		return nil, nil
+	}
+
+	var m map[string]interface{}
+	err := json.Unmarshal([]byte(u.WebhookHttpHeaders), &m)
+	if err != nil {
+		return nil, err
+	}
+	headers := make(map[string][]string)
+	for name, rawValue := range m {
+		switch val := rawValue.(type) {
+		case []interface{}:
+			for _, v := range val {
+				s, ok := v.(string)
+				if !ok {
+					return nil, errors.Errorf("expected string array element, got %T", v)
+				}
+				headers[name] = append(headers[name], s)
+			}
+		case string:
+			headers[name] = []string{val}
+		default:
+			return nil, errors.Errorf("expected string or array, got %T", val)
+		}
+	}
+	return headers, nil
+}
+
 // ToLogLevel returns the LogLevel object corresponding to the user-passed
 // log level.
 func (u UserConfig) ToLogLevel() logging.LogLevel {


Test patch:
diff --git a/cmd/server_test.go b/cmd/server_test.go
index 7d7c1b52d5..6c186c97c9 100644
--- a/cmd/server_test.go
+++ b/cmd/server_test.go
@@ -148,6 +148,7 @@ var testFlags = map[string]interface{}{
 	VarFileAllowlistFlag:             "/path",
 	VCSStatusName:                    "my-status",
 	IgnoreVCSStatusNames:             "",
+	WebhookHttpHeaders:               `{"Authorization":"Bearer some-token","X-Custom-Header":["value1","value2"]}`,
 	WebBasicAuthFlag:                 false,
 	WebPasswordFlag:                  "atlantis",
 	WebUsernameFlag:                  "atlantis",
diff --git a/server/events/webhooks/http_test.go b/server/events/webhooks/http_test.go
new file mode 100644
index 0000000000..66862cf054
--- /dev/null
+++ b/server/events/webhooks/http_test.go
@@ -0,0 +1,127 @@
+package webhooks_test
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"regexp"
+	"testing"
+
+	"github.com/runatlantis/atlantis/server/events/models"
+	"github.com/runatlantis/atlantis/server/events/webhooks"
+	"github.com/runatlantis/atlantis/server/logging"
+	. "github.com/runatlantis/atlantis/testing"
+)
+
+var httpApplyResult = webhooks.ApplyResult{
+	Workspace: "production",
+	Repo: models.Repo{
+		FullName: "runatlantis/atlantis",
+	},
+	Pull: models.PullRequest{
+		Num:        1,
+		URL:        "url",
+		BaseBranch: "main",
+	},
+	User: models.User{
+		Username: "lkysow",
+	},
+	Success: true,
+}
+
+func TestHttpWebhookWithHeaders(t *testing.T) {
+	expectedHeaders := map[string][]string{
+		"Authorization":   {"Bearer token"},
+		"X-Custom-Header": {"value1", "value2"},
+	}
+
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		Equals(t, r.Header.Get("Content-Type"), "application/json")
+		for k, v := range expectedHeaders {
+			Equals(t, r.Header.Values(k), v)
+		}
+		w.WriteHeader(http.StatusOK)
+	}))
+	defer server.Close()
+
+	webhook := webhooks.HttpWebhook{
+		Client:         &webhooks.HttpClient{Client: http.DefaultClient, Headers: expectedHeaders},
+		URL:            server.URL,
+		WorkspaceRegex: regexp.MustCompile(".*"),
+		BranchRegex:    regexp.MustCompile(".*"),
+	}
+
+	err := webhook.Send(logging.NewNoopLogger(t), httpApplyResult)
+	Ok(t, err)
+}
+
+func TestHttpWebhookNoHeaders(t *testing.T) {
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		Equals(t, r.Header.Get("Content-Type"), "application/json")
+		w.WriteHeader(http.StatusOK)
+	}))
+	defer server.Close()
+
+	webhook := webhooks.HttpWebhook{
+		Client:         &webhooks.HttpClient{Client: http.DefaultClient},
+		URL:            server.URL,
+		WorkspaceRegex: regexp.MustCompile(".*"),
+		BranchRegex:    regexp.MustCompile(".*"),
+	}
+
+	err := webhook.Send(logging.NewNoopLogger(t), httpApplyResult)
+	Ok(t, err)
+}
+
+func TestHttpWebhook500(t *testing.T) {
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusInternalServerError)
+	}))
+	defer server.Close()
+
+	webhook := webhooks.HttpWebhook{
+		Client:         &webhooks.HttpClient{Client: http.DefaultClient},
+		URL:            server.URL,
+		WorkspaceRegex: regexp.MustCompile(".*"),
+		BranchRegex:    regexp.MustCompile(".*"),
+	}
+
+	err := webhook.Send(logging.NewNoopLogger(t), httpApplyResult)
+	ErrContains(t, "sending webhook", err)
+}
+
+func TestHttpNoRegexMatch(t *testing.T) {
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		Assert(t, false, "webhook should not be sent")
+	}))
+	defer server.Close()
+
+	tt := []struct {
+		name string
+		wr   *regexp.Regexp
+		br   *regexp.Regexp
+	}{
+		{
+			name: "no workspace match",
+			wr:   regexp.MustCompile("other"),
+			br:   regexp.MustCompile(".*"),
+		},
+		{
+			name: "no branch match",
+			wr:   regexp.MustCompile(".*"),
+			br:   regexp.MustCompile("other"),
+		},
+	}
+
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			webhook := webhooks.HttpWebhook{
+				Client:         &webhooks.HttpClient{Client: http.DefaultClient},
+				URL:            server.URL,
+				WorkspaceRegex: tc.wr,
+				BranchRegex:    tc.br,
+			}
+			err := webhook.Send(logging.NewNoopLogger(t), httpApplyResult)
+			Ok(t, err)
+		})
+	}
+}
diff --git a/server/events/webhooks/webhooks_test.go b/server/events/webhooks/webhooks_test.go
index 5ee00bf599..edcd80c025 100644
--- a/server/events/webhooks/webhooks_test.go
+++ b/server/events/webhooks/webhooks_test.go
@@ -14,6 +14,7 @@
 package webhooks_test
 
 import (
+	"net/http"
 	"strings"
 	"testing"
 
@@ -43,15 +44,22 @@ func validConfigs() []webhooks.Config {
 	return []webhooks.Config{validConfig}
 }
 
+func validClients() webhooks.Clients {
+	return webhooks.Clients{
+		Slack: mocks.NewMockSlackClient(),
+		Http:  &webhooks.HttpClient{Client: http.DefaultClient},
+	}
+}
+
 func TestNewWebhooksManager_InvalidWorkspaceRegex(t *testing.T) {
 	t.Log("When given an invalid workspace regex in a config, an error is returned")
 	RegisterMockTestingT(t)
-	client := mocks.NewMockSlackClient()
+	clients := validClients()
 
 	invalidRegex := "("
 	configs := validConfigs()
 	configs[0].WorkspaceRegex = invalidRegex
-	_, err := webhooks.NewMultiWebhookSender(configs, client)
+	_, err := webhooks.NewMultiWebhookSender(configs, clients)
 	Assert(t, err != nil, "expected error")
 	Assert(t, strings.Contains(err.Error(), "error parsing regexp"), "expected regex error")
 }
@@ -59,12 +67,12 @@ func TestNewWebhooksManager_InvalidWorkspaceRegex(t *testing.T) {
 func TestNewWebhooksManager_InvalidBranchRegex(t *testing.T) {
 	t.Log("When given an invalid branch regex in a config, an error is returned")
 	RegisterMockTestingT(t)
-	client := mocks.NewMockSlackClient()
+	clients := validClients()
 
 	invalidRegex := "("
 	configs := validConfigs()
 	configs[0].BranchRegex = invalidRegex
-	_, err := webhooks.NewMultiWebhookSender(configs, client)
+	_, err := webhooks.NewMultiWebhookSender(configs, clients)
 	Assert(t, err != nil, "expected error")
 	Assert(t, strings.Contains(err.Error(), "error parsing regexp"), "expected regex error")
 }
@@ -72,13 +80,13 @@ func TestNewWebhooksManager_InvalidBranchRegex(t *testing.T) {
 func TestNewWebhooksManager_InvalidBranchAndWorkspaceRegex(t *testing.T) {
 	t.Log("When given an invalid branch and invalid workspace regex in a config, an error is returned")
 	RegisterMockTestingT(t)
-	client := mocks.NewMockSlackClient()
+	clients := validClients()
 
 	invalidRegex := "("
 	configs := validConfigs()
 	configs[0].WorkspaceRegex = invalidRegex
 	configs[0].BranchRegex = invalidRegex
-	_, err := webhooks.NewMultiWebhookSender(configs, client)
+	_, err := webhooks.NewMultiWebhookSender(configs, clients)
 	Assert(t, err != nil, "expected error")
 	Assert(t, strings.Contains(err.Error(), "error parsing regexp"), "expected regex error")
 }
@@ -86,10 +94,10 @@ func TestNewWebhooksManager_InvalidBranchAndWorkspaceRegex(t *testing.T) {
 func TestNewWebhooksManager_NoEvent(t *testing.T) {
 	t.Log("When the event key is not specified in a config, an error is returned")
 	RegisterMockTestingT(t)
-	client := mocks.NewMockSlackClient()
+	clients := validClients()
 	configs := validConfigs()
 	configs[0].Event = ""
-	_, err := webhooks.NewMultiWebhookSender(configs, client)
+	_, err := webhooks.NewMultiWebhookSender(configs, clients)
 	Assert(t, err != nil, "expected error")
 	Equals(t, "must specify \"kind\" and \"event\" keys for webhooks", err.Error())
 }
@@ -97,12 +105,12 @@ func TestNewWebhooksManager_NoEvent(t *testing.T) {
 func TestNewWebhooksManager_UnsupportedEvent(t *testing.T) {
 	t.Log("When given an unsupported event in a config, an error is returned")
 	RegisterMockTestingT(t)
-	client := mocks.NewMockSlackClient()
+	clients := validClients()
 
 	unsupportedEvent := "badevent"
 	configs := validConfigs()
 	configs[0].Event = unsupportedEvent
-	_, err := webhooks.NewMultiWebhookSender(configs, client)
+	_, err := webhooks.NewMultiWebhookSender(configs, clients)
 	Assert(t, err != nil, "expected error")
 	Equals(t, "\"event: badevent\" not supported. Only \"event: apply\" is supported right now", err.Error())
 }
@@ -110,10 +118,10 @@ func TestNewWebhooksManager_UnsupportedEvent(t *testing.T) {
 func TestNewWebhooksManager_NoKind(t *testing.T) {
 	t.Log("When the kind key is not specified in a config, an error is returned")
 	RegisterMockTestingT(t)
-	client := mocks.NewMockSlackClient()
+	clients := validClients()
 	configs := validConfigs()
 	configs[0].Kind = ""
-	_, err := webhooks.NewMultiWebhookSender(configs, client)
+	_, err := webhooks.NewMultiWebhookSender(configs, clients)
 	Assert(t, err != nil, "expected error")
 	Equals(t, "must specify \"kind\" and \"event\" keys for webhooks", err.Error())
 }
@@ -121,14 +129,14 @@ func TestNewWebhooksManager_NoKind(t *testing.T) {
 func TestNewWebhooksManager_UnsupportedKind(t *testing.T) {
 	t.Log("When given an unsupported kind in a config, an error is returned")
 	RegisterMockTestingT(t)
-	client := mocks.NewMockSlackClient()
+	clients := validClients()
 
 	unsupportedKind := "badkind"
 	configs := validConfigs()
 	configs[0].Kind = unsupportedKind
-	_, err := webhooks.NewMultiWebhookSender(configs, client)
+	_, err := webhooks.NewMultiWebhookSender(configs, clients)
 	Assert(t, err != nil, "expected error")
-	Equals(t, "\"kind: badkind\" not supported. Only \"kind: slack\" is supported right now", err.Error())
+	Equals(t, "\"kind: badkind\" not supported. Only \"kind: slack\" and \"kind: http\" are supported right now", err.Error())
 }
 
 func TestNewWebhooksManager_NoConfigSuccess(t *testing.T) {
@@ -136,23 +144,27 @@ func TestNewWebhooksManager_NoConfigSuccess(t *testing.T) {
 	t.Log("passing any client should succeed")
 	var emptyConfigs []webhooks.Config
 	emptyToken := ""
-	m, err := webhooks.NewMultiWebhookSender(emptyConfigs, webhooks.NewSlackClient(emptyToken))
+	anyClients := webhooks.Clients{
+		Slack: webhooks.NewSlackClient(emptyToken),
+		Http:  &webhooks.HttpClient{Client: http.DefaultClient},
+	}
+	m, err := webhooks.NewMultiWebhookSender(emptyConfigs, anyClients)
 	Ok(t, err)
 	Equals(t, 0, len(m.Webhooks)) // nolint: staticcheck
 
 	t.Log("passing nil client should succeed")
-	m, err = webhooks.NewMultiWebhookSender(emptyConfigs, nil)
+	m, err = webhooks.NewMultiWebhookSender(emptyConfigs, webhooks.Clients{})
 	Ok(t, err)
 	Equals(t, 0, len(m.Webhooks)) // nolint: staticcheck
 }
 func TestNewWebhooksManager_SingleConfigSuccess(t *testing.T) {
 	t.Log("When there is one valid config, function should succeed")
 	RegisterMockTestingT(t)
-	client := mocks.NewMockSlackClient()
-	When(client.TokenIsSet()).ThenReturn(true)
+	clients := validClients()
+	When(clients.Slack.TokenIsSet()).ThenReturn(true)
 
 	configs := validConfigs()
-	m, err := webhooks.NewMultiWebhookSender(configs, client)
+	m, err := webhooks.NewMultiWebhookSender(configs, clients)
 	Ok(t, err)
 	Equals(t, 1, len(m.Webhooks)) // nolint: staticcheck
 }
@@ -160,15 +172,15 @@ func TestNewWebhooksManager_SingleConfigSuccess(t *testing.T) {
 func TestNewWebhooksManager_MultipleConfigSuccess(t *testing.T) {
 	t.Log("When there are multiple valid configs, function should succeed")
 	RegisterMockTestingT(t)
-	client := mocks.NewMockSlackClient()
-	When(client.TokenIsSet()).ThenReturn(true)
+	clients := validClients()
+	When(clients.Slack.TokenIsSet()).ThenReturn(true)
 
 	var configs []webhooks.Config
 	nConfigs := 5
 	for i := 0; i < nConfigs; i++ {
 		configs = append(configs, validConfig)
 	}
-	m, err := webhooks.NewMultiWebhookSender(configs, client)
+	m, err := webhooks.NewMultiWebhookSender(configs, clients)
 	Ok(t, err)
 	Equals(t, nConfigs, len(m.Webhooks)) // nolint: staticcheck
 }
diff --git a/server/user_config_test.go b/server/user_config_test.go
index 225049f335..b37f04cf8b 100644
--- a/server/user_config_test.go
+++ b/server/user_config_test.go
@@ -3,6 +3,8 @@ package server_test
 import (
 	"testing"
 
+	"github.com/pkg/errors"
+
 	"github.com/runatlantis/atlantis/server"
 	"github.com/runatlantis/atlantis/server/events/command"
 	"github.com/runatlantis/atlantis/server/logging"
@@ -69,6 +71,49 @@ func TestUserConfig_ToAllowCommandNames(t *testing.T) {
 	}
 }
 
+func TestUserConfig_ToWebhookHttpHeaders(t *testing.T) {
+	tcs := []struct {
+		name  string
+		given string
+		want  map[string][]string
+		err   error
+	}{
+		{
+			name:  "empty",
+			given: "",
+			want:  nil,
+		},
+		{
+			name:  "happy path",
+			given: `{"Authorization":"Bearer some-token","X-Custom-Header":["value1","value2"]}`,
+			want: map[string][]string{
+				"Authorization":   {"Bearer some-token"},
+				"X-Custom-Header": {"value1", "value2"},
+			},
+		},
+		{
+			name:  "invalid json",
+			given: `{"X-Custom-Header":true}`,
+			err:   errors.New("expected string or array, got bool"),
+		},
+		{
+			name:  "invalid json array element",
+			given: `{"X-Custom-Header":[1, 2]}`,
+			err:   errors.New("expected string array element, got float64"),
+		},
+	}
+	for _, tc := range tcs {
+		t.Run(tc.name, func(t *testing.T) {
+			u := server.UserConfig{
+				WebhookHttpHeaders: tc.given,
+			}
+			got, err := u.ToWebhookHttpHeaders()
+			Equals(t, tc.want, got)
+			Equals(t, tc.err, err)
+		})
+	}
+}
+
 func TestUserConfig_ToLogLevel(t *testing.T) {
 	cases := []struct {
 		userLvl string