From d054970b0239534d21b5e5c4cee202f56bc2bd30 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?P=C4=93teris=20Caune?= <cuu508@gmail.com>
Date: Tue, 28 May 2019 14:25:29 +0300
Subject: [PATCH] Webhooks support PUT method. .Webhooks can have different
 request bodies and headers for "up" and "events".

---
 CHANGELOG.md                            |   2 +
 hc/api/models.py                        |  78 ++++---
 hc/api/tests/test_notify.py             |  86 +++++++-
 hc/api/transports.py                    |  24 +-
 hc/front/forms.py                       |  73 ++++---
 hc/front/templatetags/hc_extras.py      |   5 +
 hc/front/tests/test_add_webhook.py      |  95 +++++---
 static/css/add_webhook.css              |  36 +++
 static/js/webhook.js                    |  32 +--
 templates/front/channels.html           |  52 +++--
 templates/integrations/add_webhook.html | 279 +++++++++++++-----------
 11 files changed, 486 insertions(+), 276 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2c5122d5..592313e5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,8 @@ All notable changes to this project will be documented in this file.
 ### Improvements
 - Add the `prunetokenbucket` management command
 - Show check counts in JSON "badges" (#251)
+- Webhooks support PUT method (#249)
+- Webhooks can have different request bodies and headers for "up" and "events" (#249)
 
 ### Bug Fixes
 - Fix badges for tags containing special characters (#240, #237)
diff --git a/hc/api/models.py b/hc/api/models.py
index 141d7aae..900045c1 100644
--- a/hc/api/models.py
+++ b/hc/api/models.py
@@ -360,44 +360,62 @@ class Channel(models.Model):
         prio = int(parts[1])
         return PO_PRIORITIES[prio]
 
-    @property
-    def url_down(self):
+    def webhook_spec(self, status):
         assert self.kind == "webhook"
+
         if not self.value.startswith("{"):
             parts = self.value.split("\n")
-            return parts[0]
+            url_down = parts[0]
+            url_up = parts[1] if len(parts) > 1 else ""
+            post_data = parts[2] if len(parts) > 2 else ""
+
+            return {
+                "method": "POST" if post_data else "GET",
+                "url": url_down if status == "down" else url_up,
+                "body": post_data,
+                "headers": {},
+            }
 
         doc = json.loads(self.value)
-        return doc.get("url_down")
+        if "post_data" in doc:
+            # Legacy "post_data" in doc -- use the legacy fields
+            return {
+                "method": "POST" if doc["post_data"] else "GET",
+                "url": doc["url_down"] if status == "down" else doc["url_up"],
+                "body": doc["post_data"],
+                "headers": doc["headers"],
+            }
+
+        if status == "down" and "method_down" in doc:
+            return {
+                "method": doc["method_down"],
+                "url": doc["url_down"],
+                "body": doc["body_down"],
+                "headers": doc["headers_down"],
+            }
+        elif status == "up" and "method_up" in doc:
+            return {
+                "method": doc["method_up"],
+                "url": doc["url_up"],
+                "body": doc["body_up"],
+                "headers": doc["headers_up"],
+            }
+
+    @property
+    def down_webhook_spec(self):
+        return self.webhook_spec("down")
+
+    @property
+    def up_webhook_spec(self):
+        return self.webhook_spec("up")
+
+    @property
+    def url_down(self):
+        return self.down_webhook_spec["url"]
 
     @property
     def url_up(self):
-        assert self.kind == "webhook"
-        if not self.value.startswith("{"):
-            parts = self.value.split("\n")
-            return parts[1] if len(parts) > 1 else ""
-
-        doc = json.loads(self.value)
-        return doc.get("url_up")
-
-    @property
-    def post_data(self):
-        assert self.kind == "webhook"
-        if not self.value.startswith("{"):
-            parts = self.value.split("\n")
-            return parts[2] if len(parts) > 2 else ""
-
-        doc = json.loads(self.value)
-        return doc.get("post_data")
-
-    @property
-    def headers(self):
-        assert self.kind == "webhook"
-        if not self.value.startswith("{"):
-            return {}
-
-        doc = json.loads(self.value)
-        return doc.get("headers", {})
+        return self.up_webhook_spec["url"]
 
     @property
     def slack_team(self):
diff --git a/hc/api/tests/test_notify.py b/hc/api/tests/test_notify.py
index 9f1f034c..24303846 100644
--- a/hc/api/tests/test_notify.py
+++ b/hc/api/tests/test_notify.py
@@ -146,8 +146,8 @@ class NotifyTestCase(BaseTestCase):
         self.assertIsInstance(kwargs["data"], bytes)
 
     @patch("hc.api.transports.requests.request")
-    def test_webhooks_handle_json_value(self, mock_request):
-        definition = {"url_down": "http://foo.com"}
+    def test_legacy_webhooks_handle_json_value(self, mock_request):
+        definition = {"url_down": "http://foo.com", "post_data": "", "headers": {}}
         self._setup_data("webhook", json.dumps(definition))
         self.channel.notify(self.check)
 
@@ -157,8 +157,8 @@ class NotifyTestCase(BaseTestCase):
         )
 
     @patch("hc.api.transports.requests.request")
-    def test_webhooks_handle_json_up_event(self, mock_request):
-        definition = {"url_up": "http://bar"}
+    def test_legacy_webhooks_handle_json_up_event(self, mock_request):
+        definition = {"url_up": "http://bar", "post_data": "", "headers": {}}
 
         self._setup_data("webhook", json.dumps(definition), status="up")
         self.channel.notify(self.check)
@@ -167,7 +167,22 @@ class NotifyTestCase(BaseTestCase):
         mock_request.assert_called_with("get", "http://bar", headers=headers, timeout=5)
 
     @patch("hc.api.transports.requests.request")
-    def test_webhooks_handle_post_headers(self, mock_request):
+    def test_webhooks_handle_json_up_event(self, mock_request):
+        definition = {
+            "method_up": "GET",
+            "url_up": "http://bar",
+            "body_up": "",
+            "headers_up": {}
+        }
+
+        self._setup_data("webhook", json.dumps(definition), status="up")
+        self.channel.notify(self.check)
+
+        headers = {"User-Agent": "healthchecks.io"}
+        mock_request.assert_called_with("get", "http://bar", headers=headers, timeout=5)
+
+    @patch("hc.api.transports.requests.request")
+    def test_legacy_webhooks_handle_post_headers(self, mock_request):
         definition = {
             "url_down": "http://foo.com",
             "post_data": "data",
@@ -183,9 +198,27 @@ class NotifyTestCase(BaseTestCase):
         )
 
     @patch("hc.api.transports.requests.request")
-    def test_webhooks_handle_get_headers(self, mock_request):
+    def test_webhooks_handle_post_headers(self, mock_request):
+        definition = {
+            "method_down": "POST",
+            "url_down": "http://foo.com",
+            "body_down": "data",
+            "headers_down": {"Content-Type": "application/json"},
+        }
+
+        self._setup_data("webhook", json.dumps(definition))
+        self.channel.notify(self.check)
+
+        headers = {"User-Agent": "healthchecks.io", "Content-Type": "application/json"}
+        mock_request.assert_called_with(
+            "post", "http://foo.com", data=b"data", headers=headers, timeout=5
+        )
+
+    @patch("hc.api.transports.requests.request")
+    def test_legacy_webhooks_handle_get_headers(self, mock_request):
         definition = {
             "url_down": "http://foo.com",
+            "post_data": "",
             "headers": {"Content-Type": "application/json"},
         }
 
@@ -198,9 +231,27 @@ class NotifyTestCase(BaseTestCase):
         )
 
     @patch("hc.api.transports.requests.request")
-    def test_webhooks_allow_user_agent_override(self, mock_request):
+    def test_webhooks_handle_get_headers(self, mock_request):
+        definition = {
+            "method_down": "GET",
+            "url_down": "http://foo.com",
+            "body_down": "",
+            "headers_down": {"Content-Type": "application/json"},
+        }
+
+        self._setup_data("webhook", json.dumps(definition))
+        self.channel.notify(self.check)
+
+        headers = {"User-Agent": "healthchecks.io", "Content-Type": "application/json"}
+        mock_request.assert_called_with(
+            "get", "http://foo.com", headers=headers, timeout=5
+        )
+
+    @patch("hc.api.transports.requests.request")
+    def test_legacy_webhooks_allow_user_agent_override(self, mock_request):
         definition = {
             "url_down": "http://foo.com",
+            "post_data": "",
             "headers": {"User-Agent": "My-Agent"},
         }
 
@@ -212,11 +263,30 @@ class NotifyTestCase(BaseTestCase):
             "get", "http://foo.com", headers=headers, timeout=5
         )
 
+    @patch("hc.api.transports.requests.request")
+    def test_webhooks_allow_user_agent_override(self, mock_request):
+        definition = {
+            "method_down": "GET",
+            "url_down": "http://foo.com",
+            "body_down": "",
+            "headers_down": {"User-Agent": "My-Agent"},
+        }
+
+        self._setup_data("webhook", json.dumps(definition))
+        self.channel.notify(self.check)
+
+        headers = {"User-Agent": "My-Agent"}
+        mock_request.assert_called_with(
+            "get", "http://foo.com", headers=headers, timeout=5
+        )
+
     @patch("hc.api.transports.requests.request")
     def test_webhooks_support_variables_in_headers(self, mock_request):
         definition = {
+            "method_down": "GET",
             "url_down": "http://foo.com",
-            "headers": {"X-Message": "$NAME is DOWN"},
+            "body_down": "",
+            "headers_down": {"X-Message": "$NAME is DOWN"},
         }
 
         self._setup_data("webhook", json.dumps(definition))
diff --git a/hc/api/transports.py b/hc/api/transports.py
index b2a6b118..f61b6cf0 100644
--- a/hc/api/transports.py
+++ b/hc/api/transports.py
@@ -178,22 +178,24 @@ class Webhook(HttpTransport):
         return False
 
     def notify(self, check):
-        url = self.channel.url_down
-        if check.status == "up":
-            url = self.channel.url_up
+        spec = self.channel.webhook_spec(check.status)
+        assert spec["url"]
 
-        assert url
-
-        url = self.prepare(url, check, urlencode=True)
+        url = self.prepare(spec["url"], check, urlencode=True)
         headers = {}
-        for key, value in self.channel.headers.items():
+        for key, value in spec["headers"].items():
             headers[key] = self.prepare(value, check)
 
-        if self.channel.post_data:
-            payload = self.prepare(self.channel.post_data, check)
-            return self.post(url, data=payload.encode(), headers=headers)
-        else:
+        body = spec["body"]
+        if body:
+            body = self.prepare(body, check)
+
+        if spec["method"] == "GET":
             return self.get(url, headers=headers)
+        elif spec["method"] == "POST":
+            return self.post(url, data=body.encode(), headers=headers)
+        elif spec["method"] == "PUT":
+            return self.put(url, data=body.encode(), headers=headers)
 
 
 class Slack(HttpTransport):
diff --git a/hc/front/forms.py b/hc/front/forms.py
index a43f0856..dda7af49 100644
--- a/hc/front/forms.py
+++ b/hc/front/forms.py
@@ -1,10 +1,11 @@
 from datetime import timedelta as td
 import json
-import re
 from urllib.parse import quote, urlencode
 
 from django import forms
+from django.forms import URLField
 from django.conf import settings
+from django.core.exceptions import ValidationError
 from django.core.validators import RegexValidator
 from hc.front.validators import (
     CronExpressionValidator,
@@ -14,6 +15,37 @@ from hc.front.validators import (
 import requests
 
 
+class HeadersField(forms.Field):
+    message = """Use "Header-Name: value" pairs, one per line."""
+
+    def to_python(self, value):
+        if not value:
+            return {}
+
+        headers = {}
+        for line in value.split("\n"):
+            if not line.strip():
+                continue
+
+            if ":" not in value:
+                raise ValidationError(self.message)
+
+            n, v = line.split(":", maxsplit=1)
+            n, v = n.strip(), v.strip()
+            if not n or not v:
+                raise ValidationError(message=self.message)
+
+            headers[n] = v
+
+        return headers
+
+    def validate(self, value):
+        super().validate(value)
+        for k, v in value.items():
+            if len(k) > 1000 or len(v) > 1000:
+                raise ValidationError("Value too long")
+
+
 class NameTagsForm(forms.Form):
     name = forms.CharField(max_length=100, required=False)
     tags = forms.CharField(max_length=500, required=False)
@@ -68,49 +100,28 @@ class AddUrlForm(forms.Form):
     value = forms.URLField(max_length=1000, validators=[WebhookValidator()])
 
 
-_valid_header_name = re.compile(r"\A[^:\s][^:\r\n]*\Z").match
+METHODS = ("GET", "POST", "PUT")
 
 
 class AddWebhookForm(forms.Form):
     error_css_class = "has-error"
 
-    url_down = forms.URLField(
+    method_down = forms.ChoiceField(initial="GET", choices=zip(METHODS, METHODS))
+    body_down = forms.CharField(max_length=1000, required=False)
+    headers_down = HeadersField(required=False)
+    url_down = URLField(
         max_length=1000, required=False, validators=[WebhookValidator()]
     )
 
+    method_up = forms.ChoiceField(initial="GET", choices=zip(METHODS, METHODS))
+    body_up = forms.CharField(max_length=1000, required=False)
+    headers_up = HeadersField(required=False)
     url_up = forms.URLField(
         max_length=1000, required=False, validators=[WebhookValidator()]
     )
 
-    post_data = forms.CharField(max_length=1000, required=False)
-
-    def __init__(self, *args, **kwargs):
-        super(AddWebhookForm, self).__init__(*args, **kwargs)
-
-        self.invalid_header_names = set()
-        self.headers = {}
-        if "header_key[]" in self.data and "header_value[]" in self.data:
-            keys = self.data.getlist("header_key[]")
-            values = self.data.getlist("header_value[]")
-            for key, value in zip(keys, values):
-                if not key:
-                    continue
-
-                if not _valid_header_name(key):
-                    self.invalid_header_names.add(key)
-
-                self.headers[key] = value
-
-    def clean(self):
-        if self.invalid_header_names:
-            raise forms.ValidationError("Invalid header names")
-
-        return self.cleaned_data
-
     def get_value(self):
-        val = dict(self.cleaned_data)
-        val["headers"] = self.headers
-        return json.dumps(val, sort_keys=True)
+        return json.dumps(dict(self.cleaned_data), sort_keys=True)
 
 
 phone_validator = RegexValidator(
diff --git a/hc/front/templatetags/hc_extras.py b/hc/front/templatetags/hc_extras.py
index 2d20ae34..bff76b5d 100644
--- a/hc/front/templatetags/hc_extras.py
+++ b/hc/front/templatetags/hc_extras.py
@@ -123,3 +123,8 @@ def fix_asterisks(s):
     """ Prepend asterisks with "Combining Grapheme Joiner" characters. """
 
     return s.replace("*", "\u034f*")
+
+
+@register.filter
+def format_headers(headers):
+    return "\n".join("%s: %s" % (k, v) for k, v in headers.items())
diff --git a/hc/front/tests/test_add_webhook.py b/hc/front/tests/test_add_webhook.py
index cbe26d15..3e83d8e5 100644
--- a/hc/front/tests/test_add_webhook.py
+++ b/hc/front/tests/test_add_webhook.py
@@ -8,24 +8,32 @@ class AddWebhookTestCase(BaseTestCase):
     def test_instructions_work(self):
         self.client.login(username="alice@example.org", password="password")
         r = self.client.get(self.url)
-        self.assertContains(r, "Runs a HTTP GET or HTTP POST")
+        self.assertContains(r, "Executes an HTTP request")
 
     def test_it_adds_two_webhook_urls_and_redirects(self):
-        form = {"url_down": "http://foo.com", "url_up": "https://bar.com"}
+        form = {
+            "method_down": "GET",
+            "url_down": "http://foo.com",
+            "method_up": "GET",
+            "url_up": "https://bar.com",
+        }
 
         self.client.login(username="alice@example.org", password="password")
         r = self.client.post(self.url, form)
         self.assertRedirects(r, "/integrations/")
 
         c = Channel.objects.get()
-        self.assertEqual(
-            c.value,
-            '{"headers": {}, "post_data": "", "url_down": "http://foo.com", "url_up": "https://bar.com"}',
-        )
         self.assertEqual(c.project, self.project)
+        self.assertEqual(c.down_webhook_spec["url"], "http://foo.com")
+        self.assertEqual(c.up_webhook_spec["url"], "https://bar.com")
 
     def test_it_adds_webhook_using_team_access(self):
-        form = {"url_down": "http://foo.com", "url_up": "https://bar.com"}
+        form = {
+            "method_down": "GET",
+            "url_down": "http://foo.com",
+            "method_up": "GET",
+            "url_up": "https://bar.com",
+        }
 
         # Logging in as bob, not alice. Bob has team access so this
         # should work.
@@ -34,10 +42,8 @@ class AddWebhookTestCase(BaseTestCase):
 
         c = Channel.objects.get()
         self.assertEqual(c.project, self.project)
-        self.assertEqual(
-            c.value,
-            '{"headers": {}, "post_data": "", "url_down": "http://foo.com", "url_up": "https://bar.com"}',
-        )
+        self.assertEqual(c.down_webhook_spec["url"], "http://foo.com")
+        self.assertEqual(c.up_webhook_spec["url"], "https://bar.com")
 
     def test_it_rejects_bad_urls(self):
         urls = [
@@ -52,7 +58,12 @@ class AddWebhookTestCase(BaseTestCase):
 
         self.client.login(username="alice@example.org", password="password")
         for url in urls:
-            form = {"url_down": url, "url_up": ""}
+            form = {
+                "method_down": "GET",
+                "url_down": url,
+                "method_up": "GET",
+                "url_up": "",
+            }
 
             r = self.client.post(self.url, form)
             self.assertContains(r, "Enter a valid URL.", msg_prefix=url)
@@ -60,35 +71,41 @@ class AddWebhookTestCase(BaseTestCase):
             self.assertEqual(Channel.objects.count(), 0)
 
     def test_it_handles_empty_down_url(self):
-        form = {"url_down": "", "url_up": "http://foo.com"}
+        form = {
+            "method_down": "GET",
+            "url_down": "",
+            "method_up": "GET",
+            "url_up": "http://foo.com",
+        }
 
         self.client.login(username="alice@example.org", password="password")
         self.client.post(self.url, form)
 
         c = Channel.objects.get()
-        self.assertEqual(
-            c.value,
-            '{"headers": {}, "post_data": "", "url_down": "", "url_up": "http://foo.com"}',
-        )
+        self.assertEqual(c.down_webhook_spec["url"], "")
+        self.assertEqual(c.up_webhook_spec["url"], "http://foo.com")
 
-    def test_it_adds_post_data(self):
-        form = {"url_down": "http://foo.com", "post_data": "hello"}
+    def test_it_adds_request_body(self):
+        form = {
+            "method_down": "POST",
+            "url_down": "http://foo.com",
+            "body_down": "hello",
+            "method_up": "GET",
+        }
 
         self.client.login(username="alice@example.org", password="password")
         r = self.client.post(self.url, form)
         self.assertRedirects(r, "/integrations/")
 
         c = Channel.objects.get()
-        self.assertEqual(
-            c.value,
-            '{"headers": {}, "post_data": "hello", "url_down": "http://foo.com", "url_up": ""}',
-        )
+        self.assertEqual(c.down_webhook_spec["body"], "hello")
 
     def test_it_adds_headers(self):
         form = {
+            "method_down": "GET",
             "url_down": "http://foo.com",
-            "header_key[]": ["test", "test2"],
-            "header_value[]": ["123", "abc"],
+            "headers_down": "test:123\ntest2:abc",
+            "method_up": "GET",
         }
 
         self.client.login(username="alice@example.org", password="password")
@@ -96,16 +113,34 @@ class AddWebhookTestCase(BaseTestCase):
         self.assertRedirects(r, "/integrations/")
 
         c = Channel.objects.get()
-        self.assertEqual(c.headers, {"test": "123", "test2": "abc"})
+        self.assertEqual(
+            c.down_webhook_spec["headers"], {"test": "123", "test2": "abc"}
+        )
 
-    def test_it_rejects_bad_header_names(self):
+    def test_it_rejects_bad_headers(self):
         self.client.login(username="alice@example.org", password="password")
         form = {
+            "method_down": "GET",
             "url_down": "http://example.org",
-            "header_key[]": ["ill:egal"],
-            "header_value[]": ["123"],
+            "headers_down": "invalid-headers",
+            "method_up": "GET",
         }
 
         r = self.client.post(self.url, form)
-        self.assertContains(r, "Please use valid HTTP header names.")
+        self.assertContains(r, """invalid-headers""")
         self.assertEqual(Channel.objects.count(), 0)
+
+    def test_it_strips_headers(self):
+        form = {
+            "method_down": "GET",
+            "url_down": "http://foo.com",
+            "headers_down": " test : 123 ",
+            "method_up": "GET",
+        }
+
+        self.client.login(username="alice@example.org", password="password")
+        r = self.client.post(self.url, form)
+        self.assertRedirects(r, "/integrations/")
+
+        c = Channel.objects.get()
+        self.assertEqual(c.down_webhook_spec["headers"], {"test": "123"})
diff --git a/static/css/add_webhook.css b/static/css/add_webhook.css
index 16b00681..c90883cb 100644
--- a/static/css/add_webhook.css
+++ b/static/css/add_webhook.css
@@ -2,3 +2,39 @@
     border-color: #a94442;
 }
 
+#add-webhook-form div.bootstrap-select {
+    width: 100px;
+}
+
+.method-url-group {
+    display: flex;
+}
+
+.method-url-group > div.dropdown button {
+    border-right: 0;
+    border-top-right-radius: 0;
+    border-bottom-right-radius: 0;
+
+}
+
+.method-url-group input {
+    z-index: 1;
+    border-top-left-radius: 0;
+    border-bottom-left-radius: 0;
+}
+
+#webhook-variables tr:first-child th, #webhook-variables tr:first-child td {
+    border-top: 0;
+}
+
+#webhook-variables th {
+    white-space: nowrap;
+}
+
+.label-down {
+    color: #d9534f;
+}
+
+.label-up {
+    color: #5cb85c
+}
\ No newline at end of file
diff --git a/static/js/webhook.js b/static/js/webhook.js
index 2e7cc9f2..2a9090f5 100644
--- a/static/js/webhook.js
+++ b/static/js/webhook.js
@@ -1,25 +1,15 @@
 $(function() {
-    function haveBlankHeaderForm() {
-        return $("#webhook-headers .webhook-header").filter(function() {
-            var key = $(".key", this).val();
-            var value = $(".value", this).val();
-            return !key && !value;
-        }).length;
-    }
+    $("#method-down").change(function() {
+        var method = this.value;
+        $("#body-down-group").toggle(method != "GET");
+    });
 
-    function ensureBlankHeaderForm() {
-        if (!haveBlankHeaderForm()) {
-            var tmpl = $("#header-template").html();
-            $("#webhook-headers").append(tmpl);
-        }
-    }
+    $("#method-up").change(function() {
+        var method = this.value;
+        $("#body-up-group").toggle(method != "GET");
+    });
 
-    $("#webhook-headers").on("click", "button", function(e) {
-        e.preventDefault();
-        $(this).closest(".webhook-header").remove();
-        ensureBlankHeaderForm();
-    })
-
-    $("#webhook-headers").on("keyup", "input", ensureBlankHeaderForm);
-    ensureBlankHeaderForm();
+    // On page load, check if we need to show "request body" fields
+    $("#method-down").trigger("change");
+    $("#method-up").trigger("change");
 });
\ No newline at end of file
diff --git a/templates/front/channels.html b/templates/front/channels.html
index 74e6fbee..c60585df 100644
--- a/templates/front/channels.html
+++ b/templates/front/channels.html
@@ -398,30 +398,38 @@
                     </div>
 
                     {% if ch.kind == "webhook" %}
+                    {% with ch.down_webhook_spec as spec %}
+                        {% if spec.url %}
+                            <p><strong>Execute on "down" events:</strong></p>
+                            <pre>{{ spec.method }} {{ spec.url }}</pre>
+                            {% if spec.body %}
+                                <p>Request Body</p>
+                                <pre>{{ spec.body }}</pre>
+                            {% endif %}
 
-                    {% if ch.url_down %}
-                        <p><strong>URL for "down" events</strong></p>
-                        <pre>{{ ch.url_down }}</pre>
+                            {% if spec.headers %}
+                                <p>Request Headers</p>
+                                <pre>{{ spec.headers|format_headers }}</pre>
+                            {% endif %}
+                        {% endif %}
+                        {% endwith %}
+
+                        {% with ch.up_webhook_spec as spec %}
+                        {% if spec.url %}
+                            <p><strong>Execute on "up" events:</strong></p>
+                            <pre>{{ spec.method }} {{ spec.url }}</pre>
+                            {% if spec.body %}
+                                <p>Request Body</p>
+                                <pre>{{ spec.body }}</pre>
+                            {% endif %}
+
+                            {% if spec.headers %}
+                                <p>Request Headers</p>
+                                <pre>{{ spec.headers|format_headers }}</pre>
+                            {% endif %}
+                        {% endif %}
+                    {% endwith %}
                     {% endif %}
-
-                    {% if ch.url_up %}
-                        <p><strong>URL for "up" events</strong></p>
-                        <pre>{{ ch.url_up }}</pre>
-                    {% endif %}
-
-                    {% if ch.post_data %}
-                        <p><strong>POST data</strong></p>
-                        <pre>{{ ch.post_data }}</pre>
-                    {% endif %}
-
-
-                    {% for k, v in ch.headers.items %}
-                    <p><strong>Header <code>{{ k }}</code></strong></p>
-                    <pre>{{ v }}</pre>
-                    {% endfor %}
-                    {% endif %}
-
-
                 </div>
                 <div class="modal-footer">
                     <button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
diff --git a/templates/integrations/add_webhook.html b/templates/integrations/add_webhook.html
index 3b1cffa0..e739fc16 100644
--- a/templates/integrations/add_webhook.html
+++ b/templates/integrations/add_webhook.html
@@ -6,168 +6,201 @@
 
 {% block content %}
 <div class="row">
-<div class="col-sm-12">
-    <h1>Webhook</h1>
+    <div class="col-sm-12">
+        <h1>Webhook</h1>
 
-    <p>Runs a HTTP GET or HTTP POST to your specified URL when a check
-    goes up or down. Uses GET by default, and uses POST if you specify
-    any POST data.</p>
+        <p>Executes an HTTP request to your specified URL when a check
+        goes up or down.</p>
 
-    <p>You can use the following variables in webhook URLs:</p>
-    <table class="table webhook-variables">
-        <tr>
-            <th class="variable-column">Variable</th>
-            <td>Will be replaced with…</td>
-        </tr>
-        <tr>
-            <th><code>$CODE</code></th>
-            <td>The UUID code of the check</td>
-        </tr>
-        <tr>
-            <th><code>$NAME</code></th>
-            <td>Name of the check</td>
-        </tr>
-        <tr>
-            <th><code>$NOW</code></th>
-            <td>
-                Current UTC time in ISO8601 format.
-                Example: "{{ now }}"
-            </td>
-        </tr>
-        <tr>
-            <th><code>$STATUS</code></th>
-            <td>Check's current status ("up" or "down")</td>
-        </tr>
-        <tr>
-            <th><code>$TAG1, $TAG2, …</code></th>
-            <td>Value of the first tag, the second tag, …</td>
-        </tr>
-    </table>
+        <p>
+            You can use placeholders <strong>$NAME</strong>, <strong>$STATUS</strong> and others in webhook URLs,
+            request body and header values
+            <a href="#" data-toggle="modal" data-target="#reference-modal">(quick reference)</a>.
+        </p>
 
-    <p>For example, a callback URL using variables might look like so:
-    <pre>http://requestb.in/1hhct291?message=<strong>$NAME</strong>:<strong>$STATUS</strong></pre>
-
-    <p>
-        After encoding and replacing the variables, {% site_name %} would then call:
-    </p>
-    <pre>http://requestb.in/1hhct291?message=<strong>My%20Check</strong>:<strong>down</strong></pre>
-
-    <h2>Integration Settings</h2>
-
-    <form method="post" class="form-horizontal">
+        <form id="add-webhook-form" method="post">
         {% csrf_token %}
-        <input type="hidden" name="kind" value="webhook" />
-        <div class="form-group {{ form.url_down.css_classes }}">
-            <label class="col-sm-2 control-label">URL for "down" events</label>
-            <div class="col-sm-10">
-                <input
-                    type="text"
-                    class="form-control"
-                    name="url_down"
-                    placeholder="http://..."
-                    value="{{ form.url_down.value|default:"" }}">
+
+        <div class="row">
+        <div class="col-sm-6">
+            <h2>Execute when a check goes <strong class="label-down">down</strong></h2>
+            <br />
+
+            <div class="form-group {{ form.url_down.css_classes }}">
+                <label>URL</label>
+                <div class="method-url-group">
+                    <select id="method-down" name="method_down" class="selectpicker">
+                        <option{% if form.method_down.value == "GET" %} selected{% endif %}>GET</option>
+                        <option{% if form.method_down.value == "POST" %} selected{% endif %}>POST</option>
+                        <option{% if form.method_down.value == "PUT" %} selected{% endif %}>PUT</option>
+                    </select>
+                    <input
+                        name="url_down"
+                        value="{{ form.url_down.value|default:"" }}"
+                        type="text"
+                        class="form-control"
+                        placeholder="https://..." />
+                </div>
                 {% if form.url_down.errors %}
                 <div class="help-block">
                     {{ form.url_down.errors|join:"" }}
                 </div>
                 {% endif %}
             </div>
-        </div>
-        <div class="form-group {{ form.url_up.css_classes }}">
-            <label class="col-sm-2 control-label">URL for "up" events</label>
-            <div class="col-sm-10">
-                <input
-                    type="text"
+
+            <div id="body-down-group" class="form-group {{ form.body_down.css_classes }}" style="display: none">
+                <label class="control-label">Request Body</label>
+                <textarea
                     class="form-control"
-                    name="url_up"
-                    placeholder="http://..."
-                    value="{{ form.url_up.value|default:"" }}">
+                    rows="3"
+                    name="body_down"
+                    placeholder='{"status": "$STATUS"}'>{{ form.body_down.value|default:"" }}</textarea>
+                {% if form.body_down.errors %}
+                <div class="help-block">
+                    {{ form.body_down.errors|join:"" }}
+                </div>
+                {% endif %}
+            </div>
+
+            <div class="form-group {{ form.headers_down.css_classes }}">
+                <label class="control-label">Request Headers</label>
+                <textarea
+                    class="form-control"
+                    rows="3"
+                    name="headers_down"
+                    placeholder="X-Sample-Header: $NAME has gone down">{{ form.headers_down.value|default:"" }}</textarea>
+                <div class="help-block">
+                    {% if form.headers_down.errors %}
+                        {{ form.headers_down.errors|join:"" }}
+                    {% else %}
+                        Optional "Header-Name: value" pairs, one pair per line.
+                    {% endif %}
+                </div>
+            </div>
+        </div>
+
+        <div class="col-sm-6">
+            <h2>Execute when a check goes <strong class="label-up">up</strong></h2>
+            <br />
+            <div class="form-group {{ form.url_up.css_classes }}">
+                <label>URL</label>
+                <div class="method-url-group">
+                    <select id="method-up" name="method_up" class="selectpicker">
+                        <option{% if form.method_up.value == "GET" %} selected{% endif %}>GET</option>
+                        <option{% if form.method_up.value == "POST" %} selected{% endif %}>POST</option>
+                        <option{% if form.method_up.value == "PUT" %} selected{% endif %}>PUT</option>
+                    </select>
+                    <input
+                        name="url_up"
+                        value="{{ form.url_up.value|default:"" }}"
+                        type="text"
+                        class="form-control"
+                        placeholder="https://..." />
+                </div>
+
                 {% if form.url_up.errors %}
                 <div class="help-block">
                     {{ form.url_up.errors|join:"" }}
                 </div>
                 {% endif %}
             </div>
-        </div>
-        <div class="form-group {{ form.post_data.css_classes }}">
-            <label class="col-sm-2 control-label">POST data</label>
-            <div class="col-sm-10">
-                <input
-                    type="text"
+            <div id="body-up-group" class="form-group {{ form.body_up.css_classes }}" style="display: none">
+                <label class="control-label">Request Body</label>
+                <textarea
                     class="form-control"
-                    name="post_data"
-                    placeholder='{"status": "$STATUS"}'
-                    value="{{ form.post_data.value|default:"" }}">
+                    rows="3"
+                    name="body_up"
+                    placeholder='{"status": "$STATUS"}'>{{ form.body_up.value|default:"" }}</textarea>
                 {% if form.post_data.errors %}
                 <div class="help-block">
                     {{ form.post_data.errors|join:"" }}
                 </div>
                 {% endif %}
             </div>
-        </div>
-        <div class="form-group">
-            <label class="col-sm-2 control-label">Request Headers</label>
-            <div class="col-xs-12 col-sm-10">
-                <div id="webhook-headers">
-                    {% for k, v in form.headers.items %}
-                    <div class="form-inline webhook-header">
-                        <input
-                            type="text"
-                            class="form-control key {% if k in form.invalid_header_names %}error{% endif %}"
-                            name="header_key[]"
-                            placeholder="Content-Type"
-                            value="{{ k }}" />
-                        <input
-                            type="text"
-                            class="form-control value"
-                            name="header_value[]"
-                            placeholder="application/json"
-                            value="{{ v }}" />
-                            <button class="btn btn-default" type="button">
-                                <span class="icon-delete"></span>
-                            </button>
-                    </div>
-                    {% endfor %}
+
+            <div class="form-group {{ form.headers_up.css_classes }}">
+                <label class="control-label">Request Headers</label>
+                <textarea
+                    class="form-control"
+                    rows="3"
+                    name="headers_up"
+                    placeholder="X-Sample-Header: $NAME is back up">{{ form.headers_up.value|default:"" }}</textarea>
+                <div class="help-block">
+                    {% if form.headers_up.errors %}
+                        {{ form.headers_up.errors|join:"" }}
+                    {% else %}
+                        Optional "Header-Name: value" pairs, one pair per line.
+                    {% endif %}
                 </div>
-                {% if form.invalid_header_names %}
-                <div class="text-danger">
-                    Please use valid HTTP header names.
-                </div>
-                {% endif %}
             </div>
         </div>
-        <div class="form-group">
-            <div class="col-sm-offset-2 col-sm-10">
+        </div>
+
+        <div class="form-group" class="clearfix">
+            <br>
+            <br>
+            <div class="text-right">
                 <button type="submit" class="btn btn-primary">Save Integration</button>
             </div>
         </div>
-    </form>
-</div>
-</div>
+        </form>
 
-<div id="header-template" class="hide">
-    <div class="form-inline webhook-header">
-        <input
-            type="text"
-            class="form-control key"
-            name="header_key[]"
-            placeholder="Content-Type" />
-        <input
-            type="text"
-            class="form-control value"
-            name="header_value[]"
-            placeholder="application/json" />
-            <button class="btn btn-default" type="button">
-                <span class="icon-delete"></span>
-            </button>
     </div>
 </div>
+
+<div id="reference-modal" class="modal">
+    <div class="modal-dialog">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h4>Supported Placeholders</h4>
+            </div>
+            <div class="modal-body">
+                <p>
+                    You can use the below placeholders in webhook URL, request body
+                    and header values.  {% site_name %} will replace the placeholders
+                    with the correct values.
+                </p>
+
+                <table id="webhook-variables" class="table modal-body">
+                    <tr>
+                        <th><code>$CODE</code></th>
+                        <td>The UUID code of the check</td>
+                    </tr>
+                    <tr>
+                        <th><code>$NAME</code></th>
+                        <td>Name of the check</td>
+                    </tr>
+                    <tr>
+                        <th><code>$NOW</code></th>
+                        <td>
+                            Current UTC time in ISO8601 format.
+                            Example: "{{ now }}"
+                        </td>
+                    </tr>
+                    <tr>
+                        <th><code>$STATUS</code></th>
+                        <td>Check's current status ("up" or "down")</td>
+                    </tr>
+                    <tr>
+                        <th><code>$TAG1, $TAG2, …</code></th>
+                        <td>Value of the first tag, the second tag, …</td>
+                    </tr>
+                </table>
+            </div>
+            <div class="modal-footer">
+                <button type="button" class="btn btn-default" data-dismiss="modal">Got It!</button>
+            </div>
+        </div>
+    </div>
+</div>
+
 {% endblock %}
 
 {% block scripts %}
 {% compress js %}
 <script src="{% static 'js/jquery-2.1.4.min.js' %}"></script>
 <script src="{% static 'js/bootstrap.min.js' %}"></script>
+<script src="{% static 'js/bootstrap-select.min.js' %}"></script>
 <script src="{% static 'js/webhook.js' %}"></script>
 {% endcompress %}
 {% endblock %}