diff --git a/helpers/hooks.go b/helpers/hooks.go
new file mode 100644
index 0000000000000000000000000000000000000000..3d775c6fa4cfec14732a1857d061826a96592d89
--- /dev/null
+++ b/helpers/hooks.go
@@ -0,0 +1,47 @@
+package helpers
+
+import (
+	"fmt"
+
+	"github.com/reviewboard/rb-gateway/repositories/hooks"
+)
+
+// Create a hooks.WebhookStore for testing.
+//
+// The target URLs will all be based off the given `baseUrl`.
+func CreateTestWebhookStore(baseUrl string) hooks.WebhookStore {
+	return hooks.WebhookStore{
+		"webhook-1": &hooks.Webhook{
+			Id:      "wehook-1",
+			Url:     fmt.Sprintf("%s/webhook-1", baseUrl),
+			Secret:  "top-secret-1",
+			Enabled: true,
+			Events:  []string{"push"},
+			Repos:   []string{"git-repo"},
+		},
+		"webhook-2": &hooks.Webhook{
+			Id:      "wehook-2",
+			Url:     fmt.Sprintf("%s/webhook-2", baseUrl),
+			Secret:  "top-secret-2",
+			Enabled: true,
+			Events:  []string{"push"},
+			Repos:   []string{"hg-repo"},
+		},
+		"webhook-3": &hooks.Webhook{
+			Id:      "wehook-3",
+			Url:     fmt.Sprintf("%s/webhook-3", baseUrl),
+			Secret:  "top-secret-3",
+			Enabled: false,
+			Events:  []string{"push"},
+			Repos:   []string{"git-repo"},
+		},
+		"webhook-4": &hooks.Webhook{
+			Id:      "wehook-3",
+			Url:     fmt.Sprintf("%s/webhook-4", baseUrl),
+			Secret:  "top-secret-4",
+			Enabled: false,
+			Events:  []string{"other-event"},
+			Repos:   []string{"git-repo"},
+		},
+	}
+}
diff --git a/helpers/http.go b/helpers/http.go
new file mode 100644
index 0000000000000000000000000000000000000000..2f06fc328541b65862ef1c2e32bb390899fbe084
--- /dev/null
+++ b/helpers/http.go
@@ -0,0 +1,90 @@
+package helpers
+
+import (
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+)
+
+const (
+	maxRequestBufferSize = 100
+)
+
+// A request recorded from `CreateRequestRecorder`.
+type RecordedRequest struct {
+	// The recorded request.
+	Request *http.Request
+
+	// The recorded request body.
+	//
+	// The server will call `Close()` on the body when it has finished
+	// processing the request, so it will no longer be readable on the request
+	// itself.
+	Body []byte
+}
+
+// Create a request recorded.
+//
+// The caller is responsible for shutting down the server.
+//
+// Due to channels being bounded, there is a maximum of 100 recorded requests
+// before the server will start blocking requests.
+//
+// ```go
+// func Test(t *testing.T) {
+//     assert := assert.New(t)
+//
+//     server, reqs := helpers.CreateRequestRecorder(t)
+//     defer server.Close()
+//
+//     // Make a request against the server.
+//     rsp, err := http.Get(server.URL + "/test-url")
+//     assert.Nil(t, err)
+//
+//     Retrieve the recorded request.
+//     recorded := <- reqs
+//     assert.Equal([]byte("Hello, world!"), recorded.Body)
+// }
+// ```
+func CreateRequestRecorder(t *testing.T) (*httptest.Server, <-chan RecordedRequest) {
+	ch := make(chan RecordedRequest, maxRequestBufferSize)
+	server := httptest.NewServer(requestRecorder(t, ch))
+
+	return server, ch
+}
+
+func requestRecorder(t *testing.T, send chan<- RecordedRequest) http.Handler {
+	return http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
+		t.Helper()
+
+		body, err := ioutil.ReadAll(r.Body)
+		assert.Nil(t, err)
+		send <- RecordedRequest{
+			Request: r,
+			Body:    body,
+		}
+	})
+}
+
+// Require that the channel has at least `num` requests recorded.
+//
+// There is a 5 second timeout before the function will fail.
+func AssertNumRequests(t *testing.T, num int, recv <-chan RecordedRequest) []RecordedRequest {
+	requests := make([]RecordedRequest, 0, num)
+
+	for i := 0; i < num; i++ {
+		select {
+		case request := <-recv:
+			requests = append(requests, request)
+
+		case <-time.After(5 * time.Second):
+			t.Fatalf("Timed out waiting for request %d", i)
+		}
+	}
+
+	return requests
+}
diff --git a/repositories/hooks/webhook.go b/repositories/hooks/webhook.go
index 046209c62b4a7db50bf819f25e6c10c3c5f6708d..249d553634c8a32e89671b8d444aee7d60b0bce0 100644
--- a/repositories/hooks/webhook.go
+++ b/repositories/hooks/webhook.go
@@ -1,5 +1,11 @@
 package hooks
 
+import (
+	"crypto/hmac"
+	"crypto/sha1"
+	"encoding/hex"
+)
+
 type Webhook struct {
 	// A unique ID for the webhook.
 	Id string `json:"id"`
@@ -14,3 +20,11 @@ type Webhook struct {
 	// A sorted list of repository names that this webhook applies to.
 	Repos []string `json:"repos"`
 }
+
+// Return an HMAC-SHA1 signature of the payload using the hook's secret.
+func (hook Webhook) SignPayload(payload []byte) string {
+	hmac := hmac.New(sha1.New, []byte(hook.Secret))
+	hmac.Write(payload)
+
+	return hex.EncodeToString(hmac.Sum(nil))
+}
diff --git a/repositories/invoke.go b/repositories/invoke.go
new file mode 100644
index 0000000000000000000000000000000000000000..b80e0f1f04bfc790ebc791fed70de7d7a3e46379
--- /dev/null
+++ b/repositories/invoke.go
@@ -0,0 +1,87 @@
+package repositories
+
+import (
+	"bytes"
+	"fmt"
+	"io/ioutil"
+	"log"
+	"net/http"
+
+	"github.com/reviewboard/rb-gateway/repositories/events"
+	"github.com/reviewboard/rb-gateway/repositories/hooks"
+)
+
+// Invoke all webhooks that match the given event and repository.
+func InvokeAllHooks(
+	client *http.Client,
+	store hooks.WebhookStore,
+	event string,
+	repository Repository,
+	payload events.Payload,
+) error {
+	if !events.IsValidEvent(event) {
+		return fmt.Errorf(`Unknown event type "%s"`, event)
+	}
+
+	rawPayload, err := events.MarshalPayload(payload)
+	if err != nil {
+		return err
+	}
+
+	errs := store.ForEach(event, repository.GetName(), func(hook hooks.Webhook) error {
+		err := invokeHook(client, event, repository, hook, rawPayload)
+		if err != nil {
+			log.Printf(`Error ocurred while processing hook "%s" for URL "%s": %s`,
+				hook.Id, hook.Url, err.Error())
+		}
+
+		return err
+	})
+
+	if errs != nil {
+		return fmt.Errorf("%d errors occurred wihle processing webhooks", len(errs))
+	}
+
+	return nil
+}
+
+// Invoke a webhook.
+func invokeHook(
+	client *http.Client,
+	event string,
+	repository Repository,
+	hook hooks.Webhook,
+	rawPayload []byte,
+) error {
+	req, err := http.NewRequest("POST", hook.Url, bytes.NewBuffer(rawPayload))
+	if err != nil {
+		return err
+	}
+
+	signature := hook.SignPayload(rawPayload)
+
+	req.Header.Set("X-RBG-Signature", signature)
+	req.Header.Set("X-RBG-Event", event)
+	req.Header.Set("Content-Type", "application/json")
+
+	log.Printf(`Dispatching webhook "%s" for event "%s" for repository "%s" to URL "%s"`,
+		hook.Id, event, repository.GetName(), hook.Url)
+
+	rsp, err := client.Do(req)
+	if err != nil {
+		return err
+	}
+
+	defer rsp.Body.Close()
+	if rsp.StatusCode < 200 || rsp.StatusCode > 299 {
+		log.Printf("Expected status 2XX, received %s.", rsp.Status)
+		body, err := ioutil.ReadAll(rsp.Body)
+		if err != nil {
+			return err
+		}
+
+		log.Printf("Response body: %s", body)
+	}
+
+	return nil
+}
diff --git a/repositories/invoke_test.go b/repositories/invoke_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..6204476cc63e8390ce8e310a7593b10b78e79019
--- /dev/null
+++ b/repositories/invoke_test.go
@@ -0,0 +1,131 @@
+package repositories_test
+
+import (
+	"sort"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	"github.com/reviewboard/rb-gateway/helpers"
+	"github.com/reviewboard/rb-gateway/repositories"
+	"github.com/reviewboard/rb-gateway/repositories/events"
+)
+
+func TestInvokeAllHooks(t *testing.T) {
+	assert := assert.New(t)
+
+	repo := &repositories.GitRepository{
+		RepositoryInfo: repositories.RepositoryInfo{
+			Name: "git-repo",
+			Path: "does-not-exist",
+		},
+	}
+
+	server, requestsChan := helpers.CreateRequestRecorder(t)
+	defer server.Close()
+
+	payload := events.PushPayload{
+		Repository: "git-repo",
+		Commits: []events.PushPayloadCommit{
+			{
+				Id:      "f00f00",
+				Message: "Commit message",
+				Target: events.PushPayloadCommitTarget{
+					Branch: "master",
+				},
+			},
+		},
+	}
+
+	store := helpers.CreateTestWebhookStore(server.URL)
+
+	err := repositories.InvokeAllHooks(
+		server.Client(),
+		store,
+		events.PushEvent,
+		repo,
+		payload)
+
+	assert.Nil(err)
+
+	request := helpers.AssertNumRequests(t, 1, requestsChan)[0]
+
+	assert.Equal("/webhook-1", request.Request.URL.Path)
+	assert.Equal("push", request.Request.Header.Get("X-RBG-Event"))
+
+	json, err := events.MarshalPayload(payload)
+	assert.Nil(err)
+
+	expectedSignature := store["webhook-1"].SignPayload(json)
+
+	assert.Equal(expectedSignature, request.Request.Header.Get("X-RBG-Signature"))
+	assert.Equal(json, request.Body)
+}
+
+func TestInvokeAllHooksMultiple(t *testing.T) {
+	assert := assert.New(t)
+
+	repo := &repositories.GitRepository{
+		RepositoryInfo: repositories.RepositoryInfo{
+			Name: "git-repo",
+			Path: "does-not-exist",
+		},
+	}
+
+	server, requestsChan := helpers.CreateRequestRecorder(t)
+	defer server.Close()
+
+	payload := events.PushPayload{
+		Repository: "git-repo",
+		Commits: []events.PushPayloadCommit{
+			{
+				Id:      "f00f00",
+				Message: "Commit message",
+				Target: events.PushPayloadCommitTarget{
+					Branch: "master",
+				},
+			},
+		},
+	}
+
+	store := helpers.CreateTestWebhookStore(server.URL)
+	hook := store["webhook-3"]
+	hook.Enabled = true
+
+	err := repositories.InvokeAllHooks(
+		server.Client(),
+		store,
+		events.PushEvent,
+		repo,
+		payload)
+
+	assert.Nil(err)
+
+	requests := helpers.AssertNumRequests(t, 2, requestsChan)
+
+	// The underlying implementation for dispatching webhooks iterates over a
+	// map, for which iteration order is random. Sort the requests by URL so
+	// that we can compare them to what we expect.
+	sort.Slice(requests, func(i, j int) bool {
+		return requests[i].Request.URL.Path < requests[j].Request.URL.Path
+	})
+
+	rawJson, err := events.MarshalPayload(payload)
+	assert.Nil(err)
+	json := string(rawJson)
+
+	expectedHooks := []string{"webhook-1", "webhook-3"}
+
+	for i, r := range requests {
+		request := r.Request
+
+		expectedHookId := expectedHooks[i]
+
+		assert.Equal("/"+expectedHookId, request.URL.Path)
+
+		hook := store[expectedHookId]
+		assert.Equal(request.Header.Get("X-RBG-Signature"), hook.SignPayload(rawJson))
+		assert.Equal(json, string(r.Body))
+	}
+
+}
